Skip to content

Plugins

Python API reference for the tolomeo.plugins package.

A plugin exposes metrics and commands to its service. Derive from PluginBase for a custom plugin, or from SensorPlugin for one that reads a sensor. For the lifecycle and the Meta class pattern, see Plugin System.


PluginBase

tolomeo.plugins.PluginBase

Bases: PluginABC

Base class supplying every plugin behaviour except hardware access.

Abstract: connect and disconnect are left to the concrete plugin, so instantiating PluginBase itself raises TypeError.

Provides command registration, metric registration (input and output), and task management. The setup method automatically registers all commands and metrics declared in Meta at attach time.

PluginBase pre-registers the commands from tolomeo.commands.plugin.BASE_COMMANDS.

Subclasses must implement connect and disconnect. Use after_setup to start background tasks via task_manager.

Parameters:

Name Type Description Default
logger Logger | None

Optional logger. Defaults to the module logger.

None
Source code in tolomeo/plugins/base.py
class PluginBase(PluginABC):
    """Base class supplying every plugin behaviour except hardware access.

    Abstract: ``connect`` and ``disconnect`` are left to the concrete plugin, so
    instantiating `PluginBase` itself raises ``TypeError``.

    Provides command registration, metric registration (input and output),
    and task management. The ``setup`` method automatically registers all
    commands and metrics declared in ``Meta`` at attach time.

    ``PluginBase`` pre-registers the commands from
    `tolomeo.commands.plugin.BASE_COMMANDS`.

    Subclasses must implement ``connect`` and ``disconnect``. Use
    ``after_setup`` to start background tasks via ``task_manager``.

    Args:
        logger: Optional logger. Defaults to the module logger.

    """

    class Meta:
        commands = BASE_COMMANDS

    def __init__(self, logger: logging.Logger | None = None) -> None:
        self._logger = logger or logging.getLogger(__name__)
        self._id: str | None = None
        self._options: BaseModel | None = None
        self._cmd_registry: Dict[str, Type[PluginCmd]] = {}
        self._task_manager = TaskManager(logger)
        self._in_group: InputMetricGroup = InputMetricGroup()
        self._out_group: OutputMetricGroup = OutputMetricGroup()
        self._in_metrics_registered: List[Metric] = []
        self._out_metrics_registered: List[Metric] = []

        self._push_data_cb: Callable | None = None
        self._push_info_cb: Callable | None = None

    # --- Properties ---

    @property
    def id(self) -> str:
        """Unique identifier for this plugin instance.

        Set by the subclass inside ``connect``. Raises ``ValueError`` if
        accessed before being set.

        """
        if self._id is None:
            raise ValueError("Plugin ID has not been set")
        return self._id

    @id.setter
    def id(self, plugin_id: str) -> None:
        """Set the plugin identifier.

        Drops any resolved options: the configuration table is keyed by id, so a
        new id means a different table.

        """
        self._id = plugin_id
        self._options = None

    @property
    def options(self) -> Any:
        """This plugin's validated configuration, per ``Meta.config_model``.

        The raw ``[plugins.<id>]`` table is validated against the declared model
        on first access and reused until the plugin id changes. ``setup`` touches
        it, so a bad table stops the plugin at attach time rather than surfacing
        later, in the middle of whatever the option was needed for.

        Typed ``Any`` so a plugin can narrow the return type to its own model
        without fighting the base signature.

        Raises:
            RuntimeError: If the plugin declares no ``Meta.config_model``.
            ValueError: If accessed before ``connect`` assigned the plugin id,
                since the table is keyed by it.
            ConfigError: If the table does not satisfy the model.

        """
        if self._config_model is None:
            raise RuntimeError(
                f"{type(self).__name__} reads options but declares no Meta.config_model. "
                "Declare one, or read plugin_config directly."
            )

        if self._options is None:
            # Read through the id property so accessing options too early fails
            # with the same "ID has not been set" error as any other early read,
            # rather than silently validating an empty table.
            plugin_id = self.id
            try:
                self._options = self._config_model.model_validate(self.plugin_config)
            except ValidationError as e:
                raise ConfigError(f"invalid [plugins.{plugin_id}] table: {e}") from e

        return self._options

    @property
    def plugin_config(self) -> Dict[str, Any]:
        """Deployment configuration for this plugin, from ``[plugins.<id>]``.

        Read-only, and resolved on every access rather than at construction,
        because a plugin's id is assigned during ``connect``. The result is a
        copy, so a plugin that mutates what it reads cannot reconfigure later
        readers.

        Returns:
            The plugin's configuration table. An empty mapping when no table is
            declared for its id, or when its id has not been assigned yet.

        """
        if self._id is None:
            return {}
        return get_config().plugin_config(self._id)

    @property
    def on_data_available(self) -> Callable | None:
        """Callback invoked when new datapoints are ready to be published."""
        return self._push_data_cb

    @on_data_available.setter
    def on_data_available(self, cb: Callable) -> None:
        """Register the data-available callback."""
        self._push_data_cb = cb

    @property
    def on_info_available(self) -> Callable | None:
        """Callback invoked when new device info is ready to be published."""
        return self._push_info_cb

    @on_info_available.setter
    def on_info_available(self, cb: Callable) -> None:
        """Register the info-available callback."""
        self._push_info_cb = cb

    @property
    def in_params(self) -> List[Dict]:
        """SenML parameter dicts for all registered input metrics."""
        return self._in_group.params

    @property
    def out_params(self) -> List[Dict]:
        """SenML parameter dicts for all registered output metrics."""
        return self._out_group.params

    @property
    def task_manager(self) -> TaskManager:
        """The `TaskManager` for this plugin's background tasks."""
        return self._task_manager

    @property
    def cmd_registry(self) -> Dict[str, Type[PluginCmd]]:
        """Mapping of command name → command class for this plugin."""
        return self._cmd_registry

    # --- Concrete Methods ---

    async def setup(self) -> bool:
        """Validate the plugin's options, then register commands and metrics.

        Options are validated here rather than lazily, so a bad configuration
        table fails the attach instead of surfacing at an arbitrary later read.

        Returns:
            Always True; failures surface as exceptions.

        Raises:
            ConfigError: If the plugin's configuration table does not satisfy
                ``Meta.config_model``.

        """
        if self._config_model is not None:
            # Validate now so a bad table fails the attach, not a later read.
            self._logger.info("Options set for '%s': %s", self.id, sorted(self.plugin_config))
            _ = self.options

        for cmd_class in self._commands:
            self.register_command(cmd_class)

        self._logger.info("Registered commands: \n%s", json.dumps(sorted(list(self.cmd_registry.keys())), indent=2))

        self.register_input_metrics()
        self._logger.info("Registered input metrics: \n%s", json.dumps(self.in_params, indent=2))

        self.register_output_metrics()
        self._logger.info("Registered output metrics: \n%s", json.dumps(self.out_params, indent=2))

        return True

    def register_input_metrics(self) -> None:
        """Register the declared input metrics, un-namespaced."""
        self._in_metrics_registered = self.register_metrics(self._in_metrics, self._in_group, namespace=False)

    def register_output_metrics(self) -> None:
        """Register the declared output metrics, namespaced by plugin id."""
        self._out_metrics_registered = self.register_metrics(self._out_metrics, self._out_group, namespace=True)

    def register_metrics(self, metrics: List[Metric], group: MetricGroup, *, namespace: bool = False) -> List[Metric]:
        """Add metrics to a group, optionally prefixed with the plugin id.

        The declared metrics are copied rather than added directly, because
        several plugin instances can share one declaration and namespacing
        would otherwise rename the metric for all of them.

        Args:
            metrics: The metrics declared on ``Meta``.
            group: The group to add them to.
            namespace: Whether to prefix each name with ``<plugin id>:``.
                Output metrics are namespaced so readings from several
                plugins stay distinguishable downstream.

        Returns:
            The registered copies, in declaration order.

        """
        registered = []

        for metric in metrics:
            name = f"{self.id}:{metric.name}" if namespace else metric.name
            registered_metric = Metric(
                name=name,
                unit=metric.unit,
                type=metric.type,
                data_type=metric.data_type,
            )
            group.add(registered_metric)
            registered.append(registered_metric)

        return registered

    def register_command(self, cmd_class: Type[PluginCmd]) -> None:
        """Add a command to the registry under its class name.

        Args:
            cmd_class: Command class whose ``__name__`` becomes the key.

        """
        name = cmd_class.__name__
        self._cmd_registry[name] = cmd_class

    async def command_handler(self, cmd_name: str, payload: Dict) -> Any:
        """Execute one of this plugin's registered commands.

        Args:
            cmd_name: Command class name.
            payload: The decoded message body.

        Returns:
            The command result.

        Raises:
            UnknownCommandError: If no command is registered under `cmd_name`.
            CommandError: Any expected client error, such as an
                `InvalidPayloadError`, re-raised without a stack trace.
            Exception: Whatever the command raised, after logging it.

        """
        self._logger.info("Received command: '%s' with payload: %s", cmd_name, payload)

        cmd = self._cmd_registry.get(cmd_name, None)
        if not cmd:
            self._logger.error("Command '%s' is not registered in the command registry.", cmd_name)
            raise UnknownCommandError(cmd_name)

        context = PluginCmdContext(plugin=self, payload=payload)
        self._logger.debug("Executing command '%s' with context: %s", cmd_name, context)

        try:
            result = await cmd.invoke(context)
            self._logger.info("Command '%s' executed successfully. Result: %s", cmd_name, result)
            return result
        except CommandError:
            # Expected client error: the caller reports it without a stack trace.
            raise
        except Exception as e:
            self._logger.exception("Execution of command '%s' failed with error: %s", cmd_name, str(e))
            raise

cmd_registry property

Mapping of command name → command class for this plugin.

id property writable

Unique identifier for this plugin instance.

Set by the subclass inside connect. Raises ValueError if accessed before being set.

in_params property

SenML parameter dicts for all registered input metrics.

on_data_available property writable

Callback invoked when new datapoints are ready to be published.

on_info_available property writable

Callback invoked when new device info is ready to be published.

options property

This plugin's validated configuration, per Meta.config_model.

The raw [plugins.<id>] table is validated against the declared model on first access and reused until the plugin id changes. setup touches it, so a bad table stops the plugin at attach time rather than surfacing later, in the middle of whatever the option was needed for.

Typed Any so a plugin can narrow the return type to its own model without fighting the base signature.

Raises:

Type Description
RuntimeError

If the plugin declares no Meta.config_model.

ValueError

If accessed before connect assigned the plugin id, since the table is keyed by it.

ConfigError

If the table does not satisfy the model.

out_params property

SenML parameter dicts for all registered output metrics.

plugin_config property

Deployment configuration for this plugin, from [plugins.<id>].

Read-only, and resolved on every access rather than at construction, because a plugin's id is assigned during connect. The result is a copy, so a plugin that mutates what it reads cannot reconfigure later readers.

Returns:

Type Description
Dict[str, Any]

The plugin's configuration table. An empty mapping when no table is

Dict[str, Any]

declared for its id, or when its id has not been assigned yet.

task_manager property

The TaskManager for this plugin's background tasks.

command_handler(cmd_name, payload) async

Execute one of this plugin's registered commands.

Parameters:

Name Type Description Default
cmd_name str

Command class name.

required
payload Dict

The decoded message body.

required

Returns:

Type Description
Any

The command result.

Raises:

Type Description
UnknownCommandError

If no command is registered under cmd_name.

CommandError

Any expected client error, such as an InvalidPayloadError, re-raised without a stack trace.

Exception

Whatever the command raised, after logging it.

Source code in tolomeo/plugins/base.py
async def command_handler(self, cmd_name: str, payload: Dict) -> Any:
    """Execute one of this plugin's registered commands.

    Args:
        cmd_name: Command class name.
        payload: The decoded message body.

    Returns:
        The command result.

    Raises:
        UnknownCommandError: If no command is registered under `cmd_name`.
        CommandError: Any expected client error, such as an
            `InvalidPayloadError`, re-raised without a stack trace.
        Exception: Whatever the command raised, after logging it.

    """
    self._logger.info("Received command: '%s' with payload: %s", cmd_name, payload)

    cmd = self._cmd_registry.get(cmd_name, None)
    if not cmd:
        self._logger.error("Command '%s' is not registered in the command registry.", cmd_name)
        raise UnknownCommandError(cmd_name)

    context = PluginCmdContext(plugin=self, payload=payload)
    self._logger.debug("Executing command '%s' with context: %s", cmd_name, context)

    try:
        result = await cmd.invoke(context)
        self._logger.info("Command '%s' executed successfully. Result: %s", cmd_name, result)
        return result
    except CommandError:
        # Expected client error: the caller reports it without a stack trace.
        raise
    except Exception as e:
        self._logger.exception("Execution of command '%s' failed with error: %s", cmd_name, str(e))
        raise

register_command(cmd_class)

Add a command to the registry under its class name.

Parameters:

Name Type Description Default
cmd_class Type[PluginCmd]

Command class whose __name__ becomes the key.

required
Source code in tolomeo/plugins/base.py
def register_command(self, cmd_class: Type[PluginCmd]) -> None:
    """Add a command to the registry under its class name.

    Args:
        cmd_class: Command class whose ``__name__`` becomes the key.

    """
    name = cmd_class.__name__
    self._cmd_registry[name] = cmd_class

register_input_metrics()

Register the declared input metrics, un-namespaced.

Source code in tolomeo/plugins/base.py
def register_input_metrics(self) -> None:
    """Register the declared input metrics, un-namespaced."""
    self._in_metrics_registered = self.register_metrics(self._in_metrics, self._in_group, namespace=False)

register_metrics(metrics, group, *, namespace=False)

Add metrics to a group, optionally prefixed with the plugin id.

The declared metrics are copied rather than added directly, because several plugin instances can share one declaration and namespacing would otherwise rename the metric for all of them.

Parameters:

Name Type Description Default
metrics List[Metric]

The metrics declared on Meta.

required
group MetricGroup

The group to add them to.

required
namespace bool

Whether to prefix each name with <plugin id>:. Output metrics are namespaced so readings from several plugins stay distinguishable downstream.

False

Returns:

Type Description
List[Metric]

The registered copies, in declaration order.

Source code in tolomeo/plugins/base.py
def register_metrics(self, metrics: List[Metric], group: MetricGroup, *, namespace: bool = False) -> List[Metric]:
    """Add metrics to a group, optionally prefixed with the plugin id.

    The declared metrics are copied rather than added directly, because
    several plugin instances can share one declaration and namespacing
    would otherwise rename the metric for all of them.

    Args:
        metrics: The metrics declared on ``Meta``.
        group: The group to add them to.
        namespace: Whether to prefix each name with ``<plugin id>:``.
            Output metrics are namespaced so readings from several
            plugins stay distinguishable downstream.

    Returns:
        The registered copies, in declaration order.

    """
    registered = []

    for metric in metrics:
        name = f"{self.id}:{metric.name}" if namespace else metric.name
        registered_metric = Metric(
            name=name,
            unit=metric.unit,
            type=metric.type,
            data_type=metric.data_type,
        )
        group.add(registered_metric)
        registered.append(registered_metric)

    return registered

register_output_metrics()

Register the declared output metrics, namespaced by plugin id.

Source code in tolomeo/plugins/base.py
def register_output_metrics(self) -> None:
    """Register the declared output metrics, namespaced by plugin id."""
    self._out_metrics_registered = self.register_metrics(self._out_metrics, self._out_group, namespace=True)

setup() async

Validate the plugin's options, then register commands and metrics.

Options are validated here rather than lazily, so a bad configuration table fails the attach instead of surfacing at an arbitrary later read.

Returns:

Type Description
bool

Always True; failures surface as exceptions.

Raises:

Type Description
ConfigError

If the plugin's configuration table does not satisfy Meta.config_model.

Source code in tolomeo/plugins/base.py
async def setup(self) -> bool:
    """Validate the plugin's options, then register commands and metrics.

    Options are validated here rather than lazily, so a bad configuration
    table fails the attach instead of surfacing at an arbitrary later read.

    Returns:
        Always True; failures surface as exceptions.

    Raises:
        ConfigError: If the plugin's configuration table does not satisfy
            ``Meta.config_model``.

    """
    if self._config_model is not None:
        # Validate now so a bad table fails the attach, not a later read.
        self._logger.info("Options set for '%s': %s", self.id, sorted(self.plugin_config))
        _ = self.options

    for cmd_class in self._commands:
        self.register_command(cmd_class)

    self._logger.info("Registered commands: \n%s", json.dumps(sorted(list(self.cmd_registry.keys())), indent=2))

    self.register_input_metrics()
    self._logger.info("Registered input metrics: \n%s", json.dumps(self.in_params, indent=2))

    self.register_output_metrics()
    self._logger.info("Registered output metrics: \n%s", json.dumps(self.out_params, indent=2))

    return True

SensorPlugin

tolomeo.plugins.SensorPlugin

Bases: PluginBase, SensorMixin

Plugin base class for sensor devices.

Combines PluginBase (command registry, metric registration, task management) with SensorMixin (connection state, push-based data delivery). Subclass this to implement a concrete sensor plugin.

Subclasses must implement connect and disconnect. Declare metrics in Meta.out_metrics and commands in Meta.commands. Call await self.push_reading(raw) to publish sensor readings from a background task.

Example

A concrete plugin declares its metrics and implements the two hardware hooks::

class TemperaturePlugin(SensorPlugin):
    class Meta:
        out_metrics = [Metric("temperature", "°C")]

    async def connect(self) -> bool:
        self.id = "temperature"
        return True

    async def disconnect(self) -> bool:
        return True

Parameters:

Name Type Description Default
logger Logger | None

Optional logger. Defaults to the module logger.

None
Source code in tolomeo/plugins/sensor.py
class SensorPlugin(PluginBase, SensorMixin):
    """Plugin base class for sensor devices.

    Combines `PluginBase` (command registry, metric registration, task
    management) with `SensorMixin` (connection state, push-based data
    delivery). Subclass this to implement a concrete sensor plugin.

    Subclasses must implement `connect` and `disconnect`. Declare metrics
    in ``Meta.out_metrics`` and commands in ``Meta.commands``. Call
    ``await self.push_reading(raw)`` to publish sensor readings from a
    background task.

    Example:
        A concrete plugin declares its metrics and implements the two
        hardware hooks::

            class TemperaturePlugin(SensorPlugin):
                class Meta:
                    out_metrics = [Metric("temperature", "°C")]

                async def connect(self) -> bool:
                    self.id = "temperature"
                    return True

                async def disconnect(self) -> bool:
                    return True

    Args:
        logger: Optional logger. Defaults to the module logger.

    """

    def __init__(self, logger: logging.Logger | None = None) -> None:
        """Initialise both `PluginBase` and `SensorMixin`.

        Args:
            logger: Optional logger. Defaults to the module logger.

        """
        PluginBase.__init__(self, logger)
        SensorMixin.__init__(self, logger)

__init__(logger=None)

Initialise both PluginBase and SensorMixin.

Parameters:

Name Type Description Default
logger Logger | None

Optional logger. Defaults to the module logger.

None
Source code in tolomeo/plugins/sensor.py
def __init__(self, logger: logging.Logger | None = None) -> None:
    """Initialise both `PluginBase` and `SensorMixin`.

    Args:
        logger: Optional logger. Defaults to the module logger.

    """
    PluginBase.__init__(self, logger)
    SensorMixin.__init__(self, logger)

SensorSpec

tolomeo.plugins.SensorSpec

Describes the set of metrics exposed by a sensor.

Wraps an OutputMetricGroup and exposes convenience accessors for metric names, IDs, and SenML parameter structures used when registering metrics with a service.

Parameters:

Name Type Description Default
metrics List[Metric]

List of Metric instances that the sensor exposes.

required
Source code in tolomeo/plugins/sensor.py
class SensorSpec:
    """Describes the set of metrics exposed by a sensor.

    Wraps an `OutputMetricGroup` and exposes convenience accessors for
    metric names, IDs, and SenML parameter structures used when
    registering metrics with a service.

    Args:
        metrics: List of `Metric` instances that the sensor exposes.

    """

    def __init__(self, metrics: List[Metric]) -> None:
        self._group = OutputMetricGroup(metrics)

    # --- Properties ---

    @property
    def group(self) -> OutputMetricGroup:
        """The underlying output metric group."""
        return self._group

    @property
    def metrics(self) -> List[Metric]:
        """All `Metric` instances in this spec."""
        return self._group.metrics

    @property
    def metric_names(self) -> List[str]:
        """Short names of all metrics in this spec."""
        return self._group.names

    @property
    def metric_ids(self) -> List[str]:
        """Unique IDs of all metrics in this spec."""
        return self._group.ids

    @property
    def params(self) -> List[Dict]:
        """SenML parameter dicts for all metrics, ready for publishing."""
        return self._group.params

    # --- Concrete Methods ---

    def get_metric_by_name(self, name: str) -> Metric | None:
        """Return the metric matching `name`, or ``None`` if not found."""
        return self._group.get_by_name(name)

    def get_metric_by_id(self, id: str) -> Metric | None:
        """Return the metric matching `id`, or ``None`` if not found."""
        return self._group.get_by_id(id)

group property

The underlying output metric group.

metric_ids property

Unique IDs of all metrics in this spec.

metric_names property

Short names of all metrics in this spec.

metrics property

All Metric instances in this spec.

params property

SenML parameter dicts for all metrics, ready for publishing.

get_metric_by_id(id)

Return the metric matching id, or None if not found.

Source code in tolomeo/plugins/sensor.py
def get_metric_by_id(self, id: str) -> Metric | None:
    """Return the metric matching `id`, or ``None`` if not found."""
    return self._group.get_by_id(id)

get_metric_by_name(name)

Return the metric matching name, or None if not found.

Source code in tolomeo/plugins/sensor.py
def get_metric_by_name(self, name: str) -> Metric | None:
    """Return the metric matching `name`, or ``None`` if not found."""
    return self._group.get_by_name(name)

SensorState

tolomeo.plugins.sensor.SensorState

Bases: Enum

Connection and data state for a sensor device.

Attributes:

Name Type Description
DISCONNECTED

Sensor is not connected.

CONNECTED

Sensor is connected but not yet notifying or streaming.

NOTIFYING

Sensor is connected and sending characteristic notifications.

STREAMING

Sensor is actively streaming data continuously.

Source code in tolomeo/plugins/sensor.py
class SensorState(Enum):
    """Connection and data state for a sensor device.

    Attributes:
        DISCONNECTED: Sensor is not connected.
        CONNECTED: Sensor is connected but not yet notifying or streaming.
        NOTIFYING: Sensor is connected and sending characteristic notifications.
        STREAMING: Sensor is actively streaming data continuously.

    """

    DISCONNECTED = 0x00
    CONNECTED = auto()
    NOTIFYING = auto()
    STREAMING = auto()

BLESensorPlugin

tolomeo.plugins.ble.BLESensorPlugin

Bases: PluginBase, BLESensorMixin

BLE sensor plugin combining PluginBase with BLESensorMixin.

Abstract: parse_data is left to the concrete plugin, so instantiating BLESensorPlugin raises TypeError. Subclass it and implement parse_data to decode the device's own notification format.

Source code in tolomeo/plugins/ble/plugin.py
class BLESensorPlugin(PluginBase, BLESensorMixin):
    """BLE sensor plugin combining PluginBase with BLESensorMixin.

    Abstract: ``parse_data`` is left to the concrete plugin, so instantiating
    `BLESensorPlugin` raises ``TypeError``. Subclass it and implement
    ``parse_data`` to decode the device's own notification format.

    """

    class Meta:
        """Plugin metadata carrying the BLE-specific commands."""

        commands = BLE_COMMANDS

    def __init__(self, logger: logging.Logger | None = None) -> None:
        PluginBase.__init__(self, logger)
        BLESensorMixin.__init__(self, logger)

    async def connect(self) -> bool:
        """Connect to the discovered device, introspect it, and monitor it.

        Requires `discover` to have run. On success the plugin id becomes the
        device address and a `connection_monitor` task is started.

        Returns:
            True when the connection is up; False when it failed.

        """
        self._logger.info("Attempting to connect to BLE device...")

        try:
            self.client = BleakClient(self.device, disconnected_callback=self.on_disconnect)
            await self.client.connect()
            if self.client.is_connected:
                self.id = self.client.address
                self._logger.info("Successfully connected to BLE device.")
                await self._introspect()
                self._fsm.connect_success()  # connecting → connected
                await self.task_manager.add_task(
                    "conn_monitor", self.connection_monitor, reconnect_cb=self._reconnect_cb
                )
                return True
            else:
                self._logger.error("Failed to establish connection with BLE device.")
                self._fsm.connect_fail()  # connecting → idle
                return False

        except Exception as e:
            self._logger.error("Connection attempt failed: %s", e)
            self._fsm.connect_fail()  # connecting → idle
            return False

    async def setup(self) -> bool:
        """Register commands and metrics, then start notifications.

        Returns:
            True when setup completed; False when it failed, which is logged
            rather than raised so the caller can decide whether to give up.

        """
        try:
            await super().setup()
            await self.start_notify()
            return True

        except Exception as e:
            self._logger.error("SensorPlugin setup failed: %s", e)
            return False

    async def disconnect(self) -> bool:
        """Stop notifications and disconnect, without triggering a reconnect.

        Marks the disconnect as user-initiated so `connection_monitor` leaves
        it alone.

        Returns:
            True when the device is disconnected.

        """
        self._logger.info("Initiating disconnect sequence...")
        self.conn_context.is_user_disconnected = True

        try:
            await self.stop_notify()

            if self._client and self._client.is_connected:
                await self._client.disconnect()
                self._logger.info("Successfully disconnected from BLE device.")
            else:
                self._logger.info("BLE client already disconnected.")

            if self._fsm.current_state in (self._fsm.notifying, self._fsm.connected, self._fsm.reconnecting):
                self._fsm.disconnect()  # notifying/connected/reconnecting → disconnected

            return True

        except Exception as e:
            self._logger.error("Error during BLE disconnect: %s", e)
            return False

Meta

Plugin metadata carrying the BLE-specific commands.

Source code in tolomeo/plugins/ble/plugin.py
class Meta:
    """Plugin metadata carrying the BLE-specific commands."""

    commands = BLE_COMMANDS

connect() async

Connect to the discovered device, introspect it, and monitor it.

Requires discover to have run. On success the plugin id becomes the device address and a connection_monitor task is started.

Returns:

Type Description
bool

True when the connection is up; False when it failed.

Source code in tolomeo/plugins/ble/plugin.py
async def connect(self) -> bool:
    """Connect to the discovered device, introspect it, and monitor it.

    Requires `discover` to have run. On success the plugin id becomes the
    device address and a `connection_monitor` task is started.

    Returns:
        True when the connection is up; False when it failed.

    """
    self._logger.info("Attempting to connect to BLE device...")

    try:
        self.client = BleakClient(self.device, disconnected_callback=self.on_disconnect)
        await self.client.connect()
        if self.client.is_connected:
            self.id = self.client.address
            self._logger.info("Successfully connected to BLE device.")
            await self._introspect()
            self._fsm.connect_success()  # connecting → connected
            await self.task_manager.add_task(
                "conn_monitor", self.connection_monitor, reconnect_cb=self._reconnect_cb
            )
            return True
        else:
            self._logger.error("Failed to establish connection with BLE device.")
            self._fsm.connect_fail()  # connecting → idle
            return False

    except Exception as e:
        self._logger.error("Connection attempt failed: %s", e)
        self._fsm.connect_fail()  # connecting → idle
        return False

disconnect() async

Stop notifications and disconnect, without triggering a reconnect.

Marks the disconnect as user-initiated so connection_monitor leaves it alone.

Returns:

Type Description
bool

True when the device is disconnected.

Source code in tolomeo/plugins/ble/plugin.py
async def disconnect(self) -> bool:
    """Stop notifications and disconnect, without triggering a reconnect.

    Marks the disconnect as user-initiated so `connection_monitor` leaves
    it alone.

    Returns:
        True when the device is disconnected.

    """
    self._logger.info("Initiating disconnect sequence...")
    self.conn_context.is_user_disconnected = True

    try:
        await self.stop_notify()

        if self._client and self._client.is_connected:
            await self._client.disconnect()
            self._logger.info("Successfully disconnected from BLE device.")
        else:
            self._logger.info("BLE client already disconnected.")

        if self._fsm.current_state in (self._fsm.notifying, self._fsm.connected, self._fsm.reconnecting):
            self._fsm.disconnect()  # notifying/connected/reconnecting → disconnected

        return True

    except Exception as e:
        self._logger.error("Error during BLE disconnect: %s", e)
        return False

setup() async

Register commands and metrics, then start notifications.

Returns:

Type Description
bool

True when setup completed; False when it failed, which is logged

bool

rather than raised so the caller can decide whether to give up.

Source code in tolomeo/plugins/ble/plugin.py
async def setup(self) -> bool:
    """Register commands and metrics, then start notifications.

    Returns:
        True when setup completed; False when it failed, which is logged
        rather than raised so the caller can decide whether to give up.

    """
    try:
        await super().setup()
        await self.start_notify()
        return True

    except Exception as e:
        self._logger.error("SensorPlugin setup failed: %s", e)
        return False