Plugin Development

Plugin Architecture

casectl plugins are Python packages that implement a simple Protocol interface. No inheritance required – if your class has the right methods, it’s a plugin (Python calls this structural subtyping, via typing.Protocol).

Plugins are discovered automatically via Python entry points, loaded by the PluginHost, and managed through the daemon lifecycle.

The Protocol Interface

Your plugin must define four class attributes and implement four methods:

from casectl.plugins.base import CasePlugin, PluginContext, PluginStatus

class MyPlugin:
    """No inheritance needed -- just implement the Protocol."""

    name = "my-plugin"
    version = "0.1.0"
    description = "What my plugin does"
    min_daemon_version = "0.1.0"

    async def setup(self, ctx: PluginContext) -> None:
        """Called once during plugin initialisation.
        Use ctx to register routes, subscribe to events, access hardware."""
        self._ctx = ctx
        self._hardware = ctx.get_hardware()

    async def start(self) -> None:
        """Called when the daemon starts.
        Launch background tasks, begin your plugin's main work."""
        pass

    async def stop(self) -> None:
        """Called when the daemon shuts down.
        Cancel tasks, clean up resources."""
        pass

    def get_status(self) -> dict[str, Any]:
        """Return current plugin status for health checks."""
        return {"status": PluginStatus.HEALTHY}

PluginContext

The PluginContext provides your plugin with access to the daemon’s subsystems:

Method Return Purpose
register_routes(router) None Register a FastAPI router (auto-mounted at /api/plugins/<name>/)
register_config(schema) None Register a Pydantic config schema for your plugin
register_commands(group) None Register a Click command group for the CLI
await get_config() dict Get your plugin’s config section (plugins.<name>)
get_hardware() HardwareRegistry Access expansion board, OLED, system info
on_event(event, handler) None Subscribe to an event
emit_event(event, data) None Publish an event (sync, fire-and-forget)
logger Logger Per-plugin logger instance

Adding API Routes

Plugins can register their own API endpoints. The daemon auto-mounts your router at /api/plugins/<plugin-name>/ – don’t add a prefix yourself:

from fastapi import APIRouter

class MyPlugin:
    async def setup(self, ctx: PluginContext) -> None:
        self._ctx = ctx

        router = APIRouter(tags=["my-plugin"])

        @router.get("/status")
        async def status():
            return self.get_status()

        @router.post("/action")
        async def do_action():
            return {"result": "done"}

        ctx.register_routes(router)

The routes above become /api/plugins/my-plugin/status and /api/plugins/my-plugin/action.

Hardware Access

Access hardware through the HardwareRegistry returned by ctx.get_hardware():

hw = ctx.get_hardware()

# Properties (may be None if hardware is absent):
hw.expansion    # ExpansionBoard (I2C 0x21) -- fan duty, LED, case temp
hw.oled         # OledDevice (I2C 0x3C) -- SSD1306 display
hw.system_info  # SystemInfo -- CPU temp, usage, memory, disk, IP

All I2C calls must go through asyncio.to_thread() to avoid blocking the event loop:

temp = await asyncio.to_thread(hw.system_info.get_cpu_temperature)

Event Bus

Subscribing to events

async def setup(self, ctx: PluginContext) -> None:
    ctx.on_event("metrics_updated", self._on_metrics)

async def _on_metrics(self, data: dict) -> None:
    cpu_temp = data.get("cpu_temp", 0.0)
    # react to new metrics...

Publishing events

ctx.emit_event("my-plugin.alert", {
    "message": "Temperature exceeded threshold",
    "value": 85.0,
})

Events are broadcast to all subscribers and also pushed to WebSocket clients.

Available events

Event Source Payload Frequency
metrics_updated system-monitor CPU, memory, disk, temps, fan duty Every 2s
daemon.started daemon None Once at startup
daemon.stopping daemon None Once at shutdown

Entry Point Registration

Register your plugin in pyproject.toml so casectl discovers it:

[project.entry-points."casectl.plugins"]
my-plugin = "my_package.plugin:MyPlugin"

The key (my-plugin) becomes the plugin’s route prefix. The name class attribute is the canonical runtime identifier.

Example: Temperature Logger Plugin

A complete example that logs temperature readings:

import asyncio
import logging
from pathlib import Path
from datetime import datetime
from typing import Any

from casectl.plugins.base import PluginContext, PluginStatus

class TempLoggerPlugin:
    name = "temp-logger"
    version = "0.1.0"
    description = "Logs CPU temperature to a file"
    min_daemon_version = "0.1.0"

    async def setup(self, ctx: PluginContext) -> None:
        self._ctx = ctx
        self._hw = ctx.get_hardware()
        self._log_path = Path.home() / ".config" / "casectl" / "temp.log"
        self._task: asyncio.Task | None = None

    async def start(self) -> None:
        self._task = asyncio.create_task(self._log_loop())

    async def stop(self) -> None:
        if self._task:
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass

    def get_status(self) -> dict[str, Any]:
        return {
            "status": PluginStatus.HEALTHY if self._task else PluginStatus.STOPPED,
            "log_file": str(self._log_path),
        }

    async def _log_loop(self) -> None:
        while True:
            try:
                si = self._hw.system_info
                if si is not None:
                    temp = await asyncio.to_thread(si.get_cpu_temperature)
                    timestamp = datetime.now().isoformat()
                    with open(self._log_path, "a") as f:
                        f.write(f"{timestamp},{temp}\n")
            except Exception as exc:
                logging.getLogger(__name__).warning("Failed to log temp: %s", exc)
            await asyncio.sleep(60)

Register and install:

[project.entry-points."casectl.plugins"]
temp-logger = "my_package.temp_logger:TempLoggerPlugin"
pip install -e .
# Restart the daemon -- the plugin appears automatically

Testing Without Hardware

casectl’s test fixtures mock the I2C bus. Your plugin tests can do the same:

import pytest
from unittest.mock import MagicMock, AsyncMock
from casectl.plugins.base import PluginContext
from casectl.hardware import HardwareRegistry

@pytest.fixture
def mock_context():
    hw = HardwareRegistry(expansion=None, oled=None, system_info=MagicMock())
    ctx = PluginContext(
        plugin_name="temp-logger",
        hardware_registry=hw,
        config_manager=AsyncMock(),
        event_bus=MagicMock(),
    )
    return ctx

@pytest.mark.asyncio
async def test_plugin_setup(mock_context):
    plugin = TempLoggerPlugin()
    await plugin.setup(mock_context)
    assert plugin._hw is not None

def test_plugin_status_before_start(mock_context):
    plugin = TempLoggerPlugin()
    # Before setup, task is None
    plugin._task = None
    status = plugin.get_status()
    assert "stopped" in str(status["status"]).lower()

Run with: pytest

Built-in Plugins

casectl ships with 8 built-in plugins. These serve as both core features and reference implementations for plugin developers:

Plugin Description
fan-control Temperature-curve PWM control with follow-temp/follow-rpi/manual/custom/off modes
led-control Rainbow, breathing, follow-temp, manual colour (16 named + hex), off
oled-display 4 cycling screens (clock, metrics, temperature, fan duty) with power control
system-monitor Periodic metrics collection and event bus emission
prometheus /metrics endpoint in Prometheus text format
mqtt Bidirectional MQTT with Home Assistant auto-discovery, state publishing, and command handling
automation Event-driven rules engine with conditions, actions, priority classes, and cooldown
alerting Alert dispatch via webhook, ntfy.sh, and SMTP channels

The automation plugin can trigger actions on any other plugin. For example, a rule can monitor metrics_updated events and, when CPU temperature exceeds a threshold, set the fan to full speed via the fan target and send a notification via the alert target.

Community Plugins

casectl ships with a complete example plugin at examples/plugins/casectl-example-plugin/ in the repository. Use it as a starting point – it includes working code, a full README with API documentation, and test examples.