Plugin Development Guide
This guide walks you through creating a FiestaBoard plugin from scratch — from initial idea through development, testing, and submitting a pull request to get it merged into the project.
What Is a Plugin?
A FiestaBoard plugin is a self-contained Python package that fetches data from an external source (an API, a local service, or computed values) and exposes that data as template variables. Users reference those variables in the page editor to build dynamic displays for their split-flap board.
Every plugin has four required components:
| Component | File | Purpose |
|---|---|---|
| Manifest | manifest.json | Metadata, configuration schema, and declared variables |
| Implementation | __init__.py | Python class that inherits from PluginBase and fetches data |
| Tests | tests/test_plugin.py | Automated tests with ≥80% code coverage |
| Documentation | README.md + docs/SETUP.md | Developer docs and user-facing setup guide |
Plugins are auto-discovered — drop a valid plugin directory into plugins/ and FiestaBoard finds it at startup. No registration step is needed.
Step 1: Set Up Your Development Environment
Before writing any code, get the dev environment running:
# Clone the repo (or your fork)
git clone https://github.com/Fiestaboard/FiestaBoard.git
cd FiestaBoard
# Create a feature branch
git checkout -b feat-plugin-my-plugin
# Copy the environment template
cp env.example .env
# Edit .env with your board API key (see README for details)
# Start the development stack (hot reload for Python and Next.js)
docker-compose -f docker-compose.dev.yml up --build
The dev environment gives you:
- API at
http://localhost:8000(auto-reloads on Python changes) - Web UI at
http://localhost:3000(hot reloads on frontend changes)
See Local Development for full setup details.
Step 2: Scaffold Your Plugin
Copy the built-in template to create your plugin skeleton:
cp -r plugins/_template plugins/my_plugin
This gives you the required directory structure:
plugins/my_plugin/
├── __init__.py # Plugin class (PluginBase subclass)
├── manifest.json # Plugin metadata and configuration schema
├── README.md # Developer-focused documentation
├── docs/
│ └── SETUP.md # User-facing setup guide (API keys, config)
└── tests/
├── __init__.py # Required (can be empty)
├── conftest.py # Shared test fixtures
└── test_plugin.py # Plugin tests (≥80% coverage required)
The plugin directory name must match the id field in manifest.json. If your directory is plugins/my_plugin/, then manifest.json must have "id": "my_plugin".
Step 3: Define Your Manifest
The manifest.json file is the heart of your plugin. It tells FiestaBoard what your plugin is, how it's configured, and what template variables it provides.
Minimal Example
{
"id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "A brief description of what this plugin does",
"author": "Your Name",
"icon": "puzzle",
"category": "utility",
"settings_schema": {
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "Your API key from example.com",
"ui:widget": "password"
}
},
"required": ["api_key"]
},
"variables": {
"simple": ["value", "status"]
},
"max_lengths": {
"value": 10,
"status": 15
}
}
Required Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier — lowercase letters, digits, and underscores only. Must start with a letter. Must match directory name. |
name | string | Human-readable name shown in the UI (max 50 characters). |
version | string | Semantic version in X.Y.Z format (e.g., "1.0.0"). |
description | string | Short description (max 200 characters). |
author | string | Plugin author or maintainer. |
settings_schema | object | JSON Schema defining user-configurable settings. |
variables | object | Template variables your plugin exposes (see Variables below). |
max_lengths | object | Maximum character lengths for each variable (used for template validation). |
Optional Fields
| Field | Type | Default | Description |
|---|---|---|---|
icon | string | "puzzle" | Lucide icon name for the UI. |
category | string | "utility" | Grouping category. Valid values: "art", "data", "transit", "weather", "entertainment", "utility", "home". |
repository | string | — | GitHub repository URL. |
documentation | string | "README.md" | Path to documentation file relative to plugin directory. |
env_vars | array | [] | Environment variables the plugin can read (see below). |
color_rules_schema | object | {} | Schema for dynamic color rules. |
Settings Schema
The settings_schema uses JSON Schema with UI widget extensions. This schema drives the configuration form in the web UI's Integrations page.
{
"settings_schema": {
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "Get your key from example.com",
"ui:widget": "password"
},
"provider": {
"type": "string",
"title": "Provider",
"enum": ["option1", "option2"],
"default": "option1"
},
"locations": {
"type": "array",
"title": "Locations",
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Name" },
"value": { "type": "string", "title": "Value" }
},
"required": ["name", "value"]
}
},
"refresh_seconds": {
"type": "integer",
"title": "Refresh Interval (seconds)",
"default": 300,
"minimum": 60,
"maximum": 3600
}
},
"required": ["api_key"]
}
}
Available UI widgets:
| Widget | Usage |
|---|---|
password | Masked input for secrets and API keys |
textarea | Multi-line text input |
select | Dropdown (automatically used for enum fields) |
timezone | Timezone picker with autocomplete |
Variables
The variables object declares what template variables your plugin provides. There are three types:
Simple Variables
Top-level key-value pairs. Users reference them as {{my_plugin.temperature}}.
{
"variables": {
"simple": ["temperature", "humidity", "condition"]
}
}
Array Variables
Indexed collections. Users reference items as {{my_plugin.locations.0.temperature}}.
{
"variables": {
"arrays": {
"locations": {
"label_field": "name",
"item_fields": ["name", "temperature", "humidity"]
}
}
}
}
label_field— the field used as a human-readable label in the UI.item_fields— all fields available on each array item.
Nested Arrays (Arrays within Arrays)
For data like transit stops that contain multiple lines. Users reference them as {{my_plugin.stops.0.lines.N.next_arrival}}.
{
"variables": {
"arrays": {
"stops": {
"label_field": "stop_name",
"item_fields": ["stop_name", "stop_code"],
"sub_arrays": {
"lines": {
"key_type": "dynamic",
"key_field": "line",
"item_fields": ["line", "next_arrival", "is_delayed"]
}
}
}
}
}
}
Max Lengths
Every variable should have a corresponding max-length entry. This helps the page editor validate that content fits the board's 22-character-wide display.
{
"max_lengths": {
"temperature": 3,
"condition": 15,
"locations.*.name": 10,
"locations.*.temperature": 3
}
}
The * wildcard matches any array index.
Environment Variables
If your plugin can read configuration from environment variables (as a fallback to UI config), declare them:
{
"env_vars": [
{
"name": "MY_PLUGIN_API_KEY",
"required": false,
"description": "API key (can also be set in the web UI)"
}
]
}
Step 4: Implement Your Plugin
Your plugin class lives in __init__.py and must inherit from PluginBase. Here's the minimal contract:
"""My Plugin for FiestaBoard."""
import logging
import os
from typing import Any, Dict, List
from src.plugins.base import PluginBase, PluginResult
logger = logging.getLogger(__name__)
class MyPlugin(PluginBase):
"""Fetches data from My Service."""
@property
def plugin_id(self) -> str:
"""Return the plugin ID — must match manifest.json 'id' field."""
return "my_plugin"
def fetch_data(self) -> PluginResult:
"""Fetch data and return it as template variables."""
api_key = self.config.get("api_key") or os.getenv("MY_PLUGIN_API_KEY")
if not api_key:
return PluginResult(available=False, error="API key not configured")
try:
# Your data-fetching logic here
data = {"value": "123", "status": "OK"}
return PluginResult(available=True, data=data)
except Exception as e:
logger.error("Error fetching data: %s", e, exc_info=True)
return PluginResult(available=False, error=str(e))
def validate_config(self, config: Dict[str, Any]) -> List[str]:
"""Validate configuration. Return a list of error messages (empty if valid)."""
errors = []
if not config.get("api_key"):
errors.append("API key is required")
return errors
PluginBase API Reference
Your plugin inherits these from PluginBase:
| Member | Type | Description |
|---|---|---|
plugin_id | property (abstract) | Required. Return your plugin's unique ID. |
fetch_data() | method (abstract) | Required. Fetch data and return a PluginResult. |
validate_config(config) | method | Validate a config dict. Return list of error strings. Default returns []. |
cleanup() | method | Called when plugin is disabled. Override to release resources. |
on_config_change(old, new) | method | Called when config is updated. Override to reset caches. |
get_formatted_display() | method | Return 6 pre-formatted lines for "single page" mode. Default returns None. |
config | property | The current configuration dictionary (set by the platform). |
enabled | property | Whether the plugin is currently enabled. |
manifest | property | The raw manifest dictionary. |
get_variables_schema() | method | Returns the variables section from the manifest. |
get_max_lengths() | method | Returns the max_lengths section from the manifest. |
get_settings_schema() | method | Returns the settings_schema section from the manifest. |
PluginResult
fetch_data() must return a PluginResult:
@dataclass
class PluginResult:
available: bool # True if data was fetched successfully
data: Optional[Dict[str, Any]] = None # Template variables
error: Optional[str] = None # Error message if fetch failed
formatted_lines: Optional[List[str]] = None # Optional pre-formatted 6-line display
- Set
available=Trueand populatedataon success. - Set
available=Falseand populateerroron failure. formatted_linesis optional — provide 6 strings (one per board row) if your plugin supports a standalone display mode.
Accessing Configuration
Use self.config (a Dict[str, Any]) to read values the user set in the web UI:
def fetch_data(self) -> PluginResult:
api_key = self.config.get("api_key")
location = self.config.get("location", "San Francisco")
refresh = self.config.get("refresh_seconds", 300)
# ...
You can also fall back to environment variables:
api_key = self.config.get("api_key") or os.getenv("MY_PLUGIN_API_KEY")
Constructor
PluginBase.__init__ accepts a manifest dictionary (the parsed manifest.json). You usually don't need to override __init__, but if you do, call super().__init__(manifest):
def __init__(self, manifest: Dict[str, Any]):
super().__init__(manifest)
self._cache = None
Real-World Example: Date & Time Plugin
This plugin has no external API — it uses Python's datetime library:
"""Date & Time plugin for FiestaBoard."""
from typing import Any, Dict, List, Optional
import logging
from datetime import datetime
import pytz
from src.plugins.base import PluginBase, PluginResult
logger = logging.getLogger(__name__)
class DateTimePlugin(PluginBase):
"""Provides current date/time in the configured timezone."""
@property
def plugin_id(self) -> str:
return "date_time"
def validate_config(self, config: Dict[str, Any]) -> List[str]:
errors = []
timezone = config.get("timezone", "America/Los_Angeles")
try:
pytz.timezone(timezone)
except pytz.exceptions.UnknownTimeZoneError:
errors.append(f"Invalid timezone: {timezone}")
return errors
def fetch_data(self) -> PluginResult:
try:
tz = pytz.timezone(self.config.get("timezone", "America/Los_Angeles"))
now = datetime.now(tz)
return PluginResult(
available=True,
data={
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M"),
"day_of_week": now.strftime("%A"),
"time_12h": now.strftime("%I:%M %p").lstrip("0"),
# ... more variables
},
)
except Exception as e:
logger.exception("Error fetching datetime data")
return PluginResult(available=False, error=str(e))
Step 5: Write Tests
All plugins must include tests with ≥80% code coverage. CI will fail if coverage is below this threshold.
Test Structure
plugins/my_plugin/tests/
├── __init__.py # Required (can be empty)
├── conftest.py # Shared test fixtures
└── test_plugin.py # Your tests (filenames must start with test_)
conftest.py
Use the template's conftest.py or create your own with shared fixtures:
"""Plugin test fixtures."""
import pytest
from src.plugins.testing import create_mock_response
@pytest.fixture(autouse=True)
def reset_plugin_singletons():
"""Reset plugin singletons before each test."""
yield
@pytest.fixture
def mock_api_response():
"""Fixture to create mock API responses."""
return create_mock_response
@pytest.fixture
def sample_config():
"""Sample configuration for testing."""
return {
"api_key": "test_api_key_12345",
"refresh_seconds": 300,
}
Writing Tests
Test the key behaviors: successful fetches, missing configuration, API errors, edge cases, and config validation.
"""Tests for my_plugin."""
import pytest
from unittest.mock import patch
from plugins.my_plugin import MyPlugin
from src.plugins.base import PluginResult
from src.plugins.testing import PluginTestCase, create_mock_response
class TestMyPlugin(PluginTestCase):
"""Core plugin tests."""
def _make_plugin(self, config=None):
"""Helper to create a configured plugin instance."""
manifest = {
"id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
}
plugin = MyPlugin(manifest)
if config:
plugin.config = config
return plugin
def test_plugin_id(self):
"""Plugin ID must match the directory name."""
plugin = self._make_plugin()
assert plugin.plugin_id == "my_plugin"
def test_fetch_data_success(self):
"""Successful fetch returns available=True with data."""
plugin = self._make_plugin({"api_key": "test_key"})
with patch("requests.get") as mock_get:
mock_get.return_value = create_mock_response(
data={"value": "42", "status": "OK"}
)
result = plugin.fetch_data()
assert result.available is True
assert result.error is None
assert "value" in result.data
def test_fetch_data_missing_config(self):
"""Missing API key returns available=False."""
plugin = self._make_plugin({})
result = plugin.fetch_data()
assert result.available is False
assert result.error is not None
@patch("requests.get")
def test_fetch_data_network_error(self, mock_get):
"""Network errors are handled gracefully."""
mock_get.side_effect = Exception("Connection refused")
plugin = self._make_plugin({"api_key": "test_key"})
result = plugin.fetch_data()
assert result.available is False
assert "Connection refused" in result.error
def test_validate_config_valid(self):
"""Valid config passes validation."""
plugin = self._make_plugin()
errors = plugin.validate_config({"api_key": "key123"})
assert len(errors) == 0
def test_validate_config_missing_required(self):
"""Missing required fields are caught."""
plugin = self._make_plugin()
errors = plugin.validate_config({})
assert len(errors) > 0
assert any("api_key" in e.lower() for e in errors)
def test_data_variables_match_manifest(self):
"""Returned data keys match the variables declared in manifest.json."""
import json
from pathlib import Path
manifest_path = Path(__file__).parent.parent / "manifest.json"
with open(manifest_path) as f:
manifest = json.load(f)
declared_vars = manifest["variables"]["simple"]
plugin = self._make_plugin({"api_key": "test_key"})
with patch("requests.get") as mock_get:
mock_get.return_value = create_mock_response(data={"value": "1", "status": "OK"})
result = plugin.fetch_data()
if result.available:
for var in declared_vars:
assert var in result.data, f"Variable '{var}' declared in manifest but missing from data"
class TestMyPluginEdgeCases:
"""Edge case and error handling tests."""
def test_empty_api_response(self):
"""Empty API response is handled gracefully."""
# ...
def test_timeout_handling(self):
"""Request timeouts produce a clean error."""
# ...
Running Tests
# Test a single plugin (with coverage)
docker-compose exec fiestaboard-api python scripts/run_plugin_tests.py --plugin=my_plugin
# Or using pytest directly
docker-compose exec fiestaboard-api pytest plugins/my_plugin/tests/ -v
# Run with coverage report
docker-compose exec fiestaboard-api pytest plugins/my_plugin/tests/ \
--cov=plugins/my_plugin --cov-report=term-missing
# Run all plugin tests
docker-compose exec fiestaboard-api python scripts/run_plugin_tests.py --verbose
# Dry run (see what would be tested)
docker-compose exec fiestaboard-api python scripts/run_plugin_tests.py --dry-run
Coverage Requirements
| Requirement | Value |
|---|---|
| Minimum coverage | 80% per plugin |
| CI enforcement | Yes — builds fail below threshold |
| Exclusions | Use # pragma: no cover sparingly for legitimately untestable code |
Step 6: Write Documentation
Each plugin needs two documentation files:
README.md (Developer-Focused)
Explain how the plugin works internally — data sources, API details, architecture decisions. This is for someone reading the code or contributing improvements.
docs/SETUP.md (User-Focused)
Walk a user through enabling and configuring the plugin. Include:
- How to obtain any required API keys (with links)
- Step-by-step configuration in the web UI
- Available template variables and example usage
- Screenshots of the plugin in action (place images in
docs/) - Troubleshooting tips
- API rate limits and plan details
Use the template's docs/SETUP.md as a starting point.
Step 7: Validate Your Plugin
Before submitting, run the validation scripts to catch common issues:
# Validate all plugin manifests (structure, schema, naming)
docker-compose exec fiestaboard-api python scripts/validate_plugins.py --verbose
# Run your plugin's tests with coverage
docker-compose exec fiestaboard-api python scripts/run_plugin_tests.py --plugin=my_plugin
# Verify the plugin loads and appears in the API
curl http://localhost:8000/plugins
curl http://localhost:8000/plugins/my_plugin
curl http://localhost:8000/plugins/my_plugin/data
The validator checks:
idmatches directory name and follows the naming rules (lowercase, underscores, starts with letter)versionfollows semantic versioning (X.Y.Z)categoryis a valid value- Required files exist (
__init__.py,manifest.json) settings_schemaandvariablesare well-formed- No duplicate plugin IDs
Step 8: Test in the Web UI
With the dev stack running, open http://localhost:3000 and:
- Go to Integrations — your plugin should appear in the list
- Enable the plugin and enter any required configuration
- Create a page using your plugin's template variables (e.g.,
{{my_plugin.value}}) - Verify the page renders correctly with live data
Step 9: Submit Your Pull Request
Pre-Submission Checklist
Before opening a PR, verify every item:
- Plugin directory name matches
manifest.jsonid -
manifest.jsonhas all required fields and valid values -
__init__.pyimplementsPluginBasewithplugin_idproperty andfetch_data()method -
validate_config()checks all required settings - Error handling is comprehensive —
fetch_data()never raises uncaught exceptions - Tests exist in
tests/with ≥80% coverage - All tests pass:
python scripts/run_plugin_tests.py --plugin=my_plugin - Plugin validation passes:
python scripts/validate_plugins.py --verbose -
README.mddocuments how the plugin works -
docs/SETUP.mdhas user-facing setup instructions - No hardcoded secrets, API keys, or real personal data
- Logging uses appropriate levels (
debug,info,warning,error)
Opening the PR
-
Push your feature branch:
git push origin feat-plugin-my-plugin -
Open a PR against
mainon GitHub. -
Title: Use the format
feat: add my_plugin plugin(or similar descriptive title). -
Description: Explain what your plugin does, what data source it uses, and mention any required API keys. Reference any related issues.
-
Scope: Keep the PR focused on one plugin. Don't bundle unrelated changes.
What CI Checks
When you open a PR, GitHub Actions automatically:
- Validates manifests — runs
scripts/validate_plugins.pyto check structure and schema - Runs plugin tests — discovers your
tests/directory and runs pytest with coverage - Checks coverage — verifies your plugin meets the 80% coverage threshold
- Verifies required files — confirms
__init__.pyandmanifest.jsonexist - Uploads coverage — reports to Codecov for review
All checks must pass before a maintainer can merge your PR.
After Merge
Once merged, add your plugin to the Available Plugins list in the repository's main README.md (if it isn't already part of your PR). Follow the existing format with emoji, plugin name, and link to your plugin's README.
Best Practices
Error Handling
Always catch exceptions in fetch_data() and return a meaningful PluginResult:
def fetch_data(self) -> PluginResult:
try:
# your logic
return PluginResult(available=True, data=data)
except requests.RequestException as e:
logger.warning("Network error: %s", e)
return PluginResult(available=False, error="Network unavailable")
except Exception as e:
logger.exception("Unexpected error in %s", self.plugin_id)
return PluginResult(available=False, error=str(e))
Caching
Avoid hitting external APIs on every data refresh. Cache responses for a reasonable interval:
from datetime import datetime, timedelta
class MyPlugin(PluginBase):
def __init__(self, manifest):
super().__init__(manifest)
self._cache = None
self._cache_time = None
self._cache_ttl = timedelta(minutes=5)
def fetch_data(self) -> PluginResult:
if self._cache and self._cache_time:
if datetime.now() - self._cache_time < self._cache_ttl:
return self._cache
result = self._fetch_fresh()
if result.available:
self._cache = result
self._cache_time = datetime.now()
return result
Logging
Use Python's logging module with appropriate levels:
logger.debug("Detailed info for debugging")
logger.info("Plugin initialized successfully")
logger.warning("Non-critical issue: %s", detail)
logger.error("Failed to fetch data: %s", error)
logger.exception("Unexpected error with traceback")
Security
- Never hardcode API keys, tokens, or credentials.
- Never log secrets — even at debug level.
- Use
"ui:widget": "password"insettings_schemafor sensitive fields. - Use generic example data in tests and documentation (e.g.,
example@example.com, well-known public coordinates).
Reference: Plugin API Endpoints
These REST endpoints are available for testing and debugging your plugin:
| Endpoint | Method | Description |
|---|---|---|
/plugins | GET | List all loaded plugins |
/plugins/{id} | GET | Get a specific plugin's details |
/plugins/{id}/config | PUT | Update plugin configuration |
/plugins/{id}/enable | POST | Enable a plugin |
/plugins/{id}/disable | POST | Disable a plugin |
/plugins/{id}/data | GET | Fetch current plugin data |
/plugins/{id}/variables | GET | Get the plugin's variable schema |
Reference: Example Plugins
Study these existing plugins as reference implementations:
| Plugin | Directory | Highlights |
|---|---|---|
| Date & Time | plugins/date_time/ | Simple plugin, no external API, simple variables only |
| Guest WiFi | plugins/guest_wifi/ | Static data, no API key needed |
| Weather | plugins/weather/ | External API with multiple providers |
| Stocks | plugins/stocks/ | Array variables with indexed items |
| Muni Transit | plugins/muni/ | Nested arrays (stops → lines) |
| Home Assistant | plugins/home_assistant/ | Dynamic entity-based variables |
| Surf | plugins/surf/ | Location-based data, caching pattern |
Next Steps
- Contributing — General contribution guidelines
- Testing Guide — Running and writing tests
- Local Development — Development environment setup