Plugin Author Guide¶
CANarchy supports third-party plugins that extend the analysis engine without requiring a fork. This guide contains two complete walkthroughs followed by reference material for all extension points.
Extension Points¶
| Extension point | Entry-point group | Use case |
|---|---|---|
ProcessorPlugin |
canarchy.processors |
Frame analysis: counters, entropy, anomaly detection, custom classifiers |
SinkPlugin |
canarchy.sinks |
Output routing: databases, SIEM, cloud telemetry, custom file formats |
InputAdapterPlugin |
canarchy.input_adapters |
Input parsing: proprietary capture formats, hardware-specific log files |
API Version¶
Every plugin must declare api_version = "1". If the version does not match the installed
CANarchy build, the plugin is rejected at registration time with a clear error message.
Walkthrough 1 — Custom Decoder Plugin¶
This walkthrough builds a ProcessorPlugin that flags arbitration IDs whose frame count
exceeds a configurable fraction of the total traffic — useful for quickly identifying dominant
talkers in a capture.
Project layout¶
canarchy-repeating-id/
├── pyproject.toml
├── my_package/
│ ├── __init__.py
│ └── plugin.py
└── tests/
└── test_repeating_id.py
Plugin code¶
# my_package/plugin.py
from typing import Any
from canarchy.models import CanFrame
from canarchy.plugins import CANARCHY_API_VERSION, ProcessorResult
class RepeatingIDDetector:
"""Flag arbitration IDs that appear suspiciously often relative to others."""
name = "repeating-id-detector"
api_version = CANARCHY_API_VERSION
def process(self, frames: list[CanFrame], **kwargs: Any) -> ProcessorResult:
from collections import Counter
counts = Counter(f.arbitration_id for f in frames if not f.is_error_frame)
total = len(frames) or 1
threshold = kwargs.get("threshold", 0.5)
candidates = [
{
"arbitration_id": arb_id,
"frame_count": count,
"fraction": round(count / total, 4),
}
for arb_id, count in counts.most_common()
if count / total >= threshold
]
warns = []
if not candidates:
warns.append(f"No arbitration ID exceeded the {threshold:.0%} repetition threshold.")
return ProcessorResult(
candidates=candidates,
metadata={
"analysis": "repeating_id_detection",
"candidate_count": len(candidates),
"threshold": threshold,
},
warnings=warns,
)
Processor plugin rules¶
namemust be a unique string across all registered processors.api_versionmust equalcanarchy.plugins.CANARCHY_API_VERSION.process()must return aProcessorResult; it must not raise on empty input.**kwargslets callers pass optional parameters; always provide safe defaults.- Do not import front-end modules (
cli,tui,mcp_server) from within a plugin.
Package and register¶
Declare the plugin via a Python entry point in your pyproject.toml:
[project]
name = "canarchy-repeating-id"
version = "0.1.0"
dependencies = ["canarchy>=0.8"]
[project.entry-points."canarchy.processors"]
repeating-id-detector = "my_package.plugin:RepeatingIDDetector"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Install the package into the same environment as CANarchy:
pip install -e .
# or
uv pip install -e .
Verify the registration¶
CANarchy discovers and registers the plugin the next time get_registry() is called (on first
command execution):
canarchy plugins list --json
canarchy plugins info repeating-id-detector --json
Write a test¶
# tests/test_repeating_id.py
import pytest
from canarchy.models import CanFrame
from canarchy.plugins import get_registry, reset_registry
from my_package.plugin import RepeatingIDDetector
@pytest.fixture(autouse=True)
def fresh_registry():
reset_registry()
yield
reset_registry()
def _frame(arb_id: int) -> CanFrame:
return CanFrame(arbitration_id=arb_id, data=b"\x00")
def test_dominant_id_detected():
plugin = RepeatingIDDetector()
frames = [_frame(0x100)] * 8 + [_frame(0x200)] * 2
result = plugin.process(frames)
assert result.candidates[0]["arbitration_id"] == 0x100
assert result.candidates[0]["fraction"] == 0.8
def test_no_dominant_id_emits_warning():
plugin = RepeatingIDDetector()
frames = [_frame(i) for i in range(10)]
result = plugin.process(frames, threshold=0.5)
assert result.warnings
def test_empty_input_does_not_raise():
plugin = RepeatingIDDetector()
result = plugin.process([])
assert result.candidates == []
def test_registered_via_entry_point():
# After `pip install -e .`, the plugin appears in the registry.
registry = get_registry()
proc = registry.get_processor("repeating-id-detector")
assert proc is not None
Enable and disable via CLI¶
# Disable the plugin (persisted in ~/.canarchy/config.toml)
canarchy plugins disable repeating-id-detector --json
# Re-enable it
canarchy plugins enable repeating-id-detector --json
# Confirm status
canarchy plugins info repeating-id-detector --json
Walkthrough 2 — Custom Sink Plugin¶
This walkthrough builds a SinkPlugin that persists command payloads to a SQLite database.
The completed implementation is the reference plugin shipped under
contrib/plugins/sqlite_sink/ in the CANarchy repository.
Project layout¶
canarchy-sqlite-sink/
├── pyproject.toml
├── sqlite_sink/
│ ├── __init__.py
│ └── plugin.py
└── tests/
└── test_sqlite_sink.py
Plugin code¶
# sqlite_sink/plugin.py
from __future__ import annotations
import json
import sqlite3
import time
from typing import Any
from canarchy.plugins import CANARCHY_API_VERSION
class SqliteSinkPlugin:
"""Write CANarchy command payloads to a SQLite database."""
name = "sqlite-sink"
api_version = CANARCHY_API_VERSION
supported_formats = ["json"]
def write(
self,
payload: dict[str, Any],
destination: str,
*,
output_format: str = "json",
) -> dict[str, Any]:
"""Insert frame events from *payload* into a SQLite DB at *destination*.
Creates the database and ``frames`` table if they do not exist.
Returns a dict with ``destination`` and ``rows_written``.
"""
con = sqlite3.connect(destination)
try:
con.execute(
"""
CREATE TABLE IF NOT EXISTS frames (
id INTEGER PRIMARY KEY,
ts REAL,
command TEXT,
payload TEXT
)
"""
)
con.commit()
command = payload.get("command", "")
events = payload.get("data", {}).get("events", [])
rows_written = 0
if events:
for event in events:
ts = event.get("ts", time.time())
con.execute(
"INSERT INTO frames (ts, command, payload) VALUES (?, ?, ?)",
(ts, command, json.dumps(event)),
)
rows_written += 1
else:
ts = time.time()
con.execute(
"INSERT INTO frames (ts, command, payload) VALUES (?, ?, ?)",
(ts, command, json.dumps(payload)),
)
rows_written = 1
con.commit()
finally:
con.close()
return {"destination": destination, "rows_written": rows_written}
Sink plugin rules¶
namemust be a unique string across all registered sinks.api_versionmust equalcanarchy.plugins.CANARCHY_API_VERSION.supported_formatslists the envelope formats the sink understands (currently"json").write()must return a dict; it must not raise on an empty"events"list.destinationis a string the caller controls — it may be a file path, a connection URL, or a topic name depending on your sink's semantics.
Package and register¶
[project]
name = "canarchy-sqlite-sink"
version = "0.1.0"
dependencies = ["canarchy>=0.8"]
[project.entry-points."canarchy.sinks"]
sqlite-sink = "sqlite_sink.plugin:SqliteSinkPlugin"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
pip install -e .
Verify the registration¶
canarchy plugins list --json
canarchy plugins info sqlite-sink --json
Write a test¶
# tests/test_sqlite_sink.py
import os
import sqlite3
import tempfile
import unittest
from canarchy.plugins import SinkPlugin
from sqlite_sink.plugin import SqliteSinkPlugin
class SqliteSinkTests(unittest.TestCase):
def setUp(self):
self.plugin = SqliteSinkPlugin()
def test_events_land_in_db(self):
with tempfile.TemporaryDirectory() as td:
db_path = os.path.join(td, "out.db")
payload = {
"command": "capture",
"data": {
"events": [
{"type": "frame", "ts": 1.0, "arbitration_id": "0x100"},
{"type": "frame", "ts": 2.0, "arbitration_id": "0x200"},
]
},
}
result = self.plugin.write(payload, db_path)
# Return contract
assert result["rows_written"] == 2
assert result["destination"] == db_path
# Rows actually landed in the database
con = sqlite3.connect(db_path)
rows = con.execute("SELECT COUNT(*) FROM frames").fetchone()[0]
con.close()
assert rows == 2
def test_db_created_if_missing(self):
with tempfile.TemporaryDirectory() as td:
db_path = os.path.join(td, "new.db")
self.plugin.write({"command": "test", "data": {}}, db_path)
assert os.path.exists(db_path)
def test_implements_sink_protocol(self):
assert isinstance(self.plugin, SinkPlugin)
Enable and disable via CLI¶
canarchy plugins disable sqlite-sink --json
canarchy plugins enable sqlite-sink --json
canarchy plugins info sqlite-sink --json
Reference implementation¶
The completed plugin is available under contrib/plugins/sqlite_sink/ in the CANarchy
repository. Clone the repo and run its tests directly:
# From the repo root
python -m pytest tests/test_contrib_sqlite_sink.py -v
Writing an Input Adapter Plugin¶
An input adapter yields CanFrame objects from a custom source.
from typing import Iterator, Any
from canarchy.models import CanFrame
from canarchy.plugins import CANARCHY_API_VERSION
class MyFormatAdapter:
name = "my-format"
api_version = CANARCHY_API_VERSION
supported_extensions = [".mylog"]
def read(self, source: str) -> Iterator[CanFrame]:
with open(source) as f:
for line in f:
arb_id, data_hex = line.strip().split(",")
yield CanFrame(
arbitration_id=int(arb_id, 16),
data=bytes.fromhex(data_hex),
)
Entry point group: canarchy.input_adapters.
Entry Point Groups Reference¶
| Group | Extension point |
|---|---|
canarchy.processors |
ProcessorPlugin |
canarchy.sinks |
SinkPlugin |
canarchy.input_adapters |
InputAdapterPlugin |
Error Isolation¶
CANarchy wraps third-party entry point loading in a try/except. If your plugin raises an
unexpected exception during loading, CANarchy emits a warnings.warn and continues starting.
A PluginError (version mismatch, duplicate name, invalid interface) propagates immediately
so you can diagnose it during development.
Compatibility Policy¶
The api_version string will increment when any breaking change is made to a plugin protocol
(removed method, changed required signature). Additive changes — new optional **kwargs,
new metadata fields in ProcessorResult — do not require a version bump.