Skip to content

OTA

Python API reference for the tolomeo.plugins.ota package.

OTAPlugin drives a firmware update from notification to installation. Derive from it and implement install_update and monitor_update; the inherited OTAMixin methods handle the rest of the lifecycle. For the states and transitions, see OTA State Machine; for the design rationale, see OTA Mechanism.


OTAPlugin

tolomeo.plugins.ota.OTAPlugin

Bases: PluginBase, OTAMixin

OTA plugin implementation.

This plugin integrates the OTA update workflow into the Tolomeo plugin architecture, combining the PluginBase interface with OTAMixin for update state management. It provides real download, installation, and lifecycle coordination behavior suitable for deployment environments.

The class binds OTA state transitions to task manager events and ensures consistent synchronization between OTA status and task execution state.

Source code in tolomeo/plugins/ota/plugin.py
class OTAPlugin(PluginBase, OTAMixin):
    """OTA plugin implementation.

    This plugin integrates the OTA update workflow into the Tolomeo
    plugin architecture, combining the `PluginBase` interface with
    `OTAMixin` for update state management. It provides real download,
    installation, and lifecycle coordination behavior suitable for
    deployment environments.

    The class binds OTA state transitions to task manager events and
    ensures consistent synchronization between OTA status and task
    execution state.

    """

    class Meta:
        """Plugin metadata container defining available OTA commands and options."""

        commands = OTA_COMMANDS
        config_model = OTAOptions

    def __init__(self, logger: logging.Logger | None = None) -> None:
        """Initialize the OTA plugin instance.

        Combines `PluginBase` and `OTAMixin` initializers, establishing
        logging and OTA state tracking behavior.

        Args:
            logger: Optional logger instance for OTA operations. If None, a default logger is created.

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

    async def before_setup(self) -> None:
        """Configure OTA-related callbacks prior to plugin setup.

        This hook prepares the OTA state machine and task manager by
        registering event hooks for download errors and task state
        transitions. It chains existing task manager callbacks with
        OTA-specific handlers to ensure OTA state synchronization. It also
        resolves the checkpoint file location and `OTAOptions.allow_same_version`
        from `self.options` (readable now that `connect` has assigned `self.id`)
        and assigns them to the state machine, which arms bootstrapping without
        performing it — `after_setup` performs the actual bootstrap.

        Raises:
            Exception: If callback setup fails or task manager references
                are invalid.

        """
        self._logger.debug("Setting up push callback for state machine")
        self._ota_fsm.push_callback = self._push_data_cb

        self._ota_fsm.checkpoint_file = self.options.checkpoint_file
        self._logger.info("OTA checkpoint file: %s", self._ota_fsm.checkpoint_file)
        self._ota_fsm.allow_same_version = self.options.allow_same_version

        def download_error_hook(task_name: str, exception: Exception) -> None:
            """Internal hook to handle download task errors."""
            if task_name == self._download_task_name:
                self._on_download_error(task_name, exception)

        def download_state_change_hook(task_name: str, old_state: str, new_state: str) -> None:
            """Internal hook to handle task state transitions for downloads."""
            if task_name == self._download_task_name:
                self._on_download_state_change(task_name, old_state, new_state)

        tm = self._task_manager
        tm._on_error = chain_callback(tm._on_error, download_error_hook)
        tm._on_state_change = chain_callback(tm._on_state_change, download_state_change_hook)

    async def after_setup(self) -> None:
        """Bootstrap OTA state after plugin setup.

        This hook is the single authoritative bootstrap for the OTA state
        machine: it calls `reset`, which resolves to whatever the checkpoint
        says — `updated`, `paused`, `update_available`, or any other
        checkpointable state — rather than assuming a fixed starting state.

        """
        await self._bootstrap()

Meta

Plugin metadata container defining available OTA commands and options.

Source code in tolomeo/plugins/ota/plugin.py
class Meta:
    """Plugin metadata container defining available OTA commands and options."""

    commands = OTA_COMMANDS
    config_model = OTAOptions

__init__(logger=None)

Initialize the OTA plugin instance.

Combines PluginBase and OTAMixin initializers, establishing logging and OTA state tracking behavior.

Parameters:

Name Type Description Default
logger Logger | None

Optional logger instance for OTA operations. If None, a default logger is created.

None
Source code in tolomeo/plugins/ota/plugin.py
def __init__(self, logger: logging.Logger | None = None) -> None:
    """Initialize the OTA plugin instance.

    Combines `PluginBase` and `OTAMixin` initializers, establishing
    logging and OTA state tracking behavior.

    Args:
        logger: Optional logger instance for OTA operations. If None, a default logger is created.

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

after_setup() async

Bootstrap OTA state after plugin setup.

This hook is the single authoritative bootstrap for the OTA state machine: it calls reset, which resolves to whatever the checkpoint says — updated, paused, update_available, or any other checkpointable state — rather than assuming a fixed starting state.

Source code in tolomeo/plugins/ota/plugin.py
async def after_setup(self) -> None:
    """Bootstrap OTA state after plugin setup.

    This hook is the single authoritative bootstrap for the OTA state
    machine: it calls `reset`, which resolves to whatever the checkpoint
    says — `updated`, `paused`, `update_available`, or any other
    checkpointable state — rather than assuming a fixed starting state.

    """
    await self._bootstrap()

before_setup() async

Configure OTA-related callbacks prior to plugin setup.

This hook prepares the OTA state machine and task manager by registering event hooks for download errors and task state transitions. It chains existing task manager callbacks with OTA-specific handlers to ensure OTA state synchronization. It also resolves the checkpoint file location and OTAOptions.allow_same_version from self.options (readable now that connect has assigned self.id) and assigns them to the state machine, which arms bootstrapping without performing it — after_setup performs the actual bootstrap.

Raises:

Type Description
Exception

If callback setup fails or task manager references are invalid.

Source code in tolomeo/plugins/ota/plugin.py
async def before_setup(self) -> None:
    """Configure OTA-related callbacks prior to plugin setup.

    This hook prepares the OTA state machine and task manager by
    registering event hooks for download errors and task state
    transitions. It chains existing task manager callbacks with
    OTA-specific handlers to ensure OTA state synchronization. It also
    resolves the checkpoint file location and `OTAOptions.allow_same_version`
    from `self.options` (readable now that `connect` has assigned `self.id`)
    and assigns them to the state machine, which arms bootstrapping without
    performing it — `after_setup` performs the actual bootstrap.

    Raises:
        Exception: If callback setup fails or task manager references
            are invalid.

    """
    self._logger.debug("Setting up push callback for state machine")
    self._ota_fsm.push_callback = self._push_data_cb

    self._ota_fsm.checkpoint_file = self.options.checkpoint_file
    self._logger.info("OTA checkpoint file: %s", self._ota_fsm.checkpoint_file)
    self._ota_fsm.allow_same_version = self.options.allow_same_version

    def download_error_hook(task_name: str, exception: Exception) -> None:
        """Internal hook to handle download task errors."""
        if task_name == self._download_task_name:
            self._on_download_error(task_name, exception)

    def download_state_change_hook(task_name: str, old_state: str, new_state: str) -> None:
        """Internal hook to handle task state transitions for downloads."""
        if task_name == self._download_task_name:
            self._on_download_state_change(task_name, old_state, new_state)

    tm = self._task_manager
    tm._on_error = chain_callback(tm._on_error, download_error_hook)
    tm._on_state_change = chain_callback(tm._on_state_change, download_state_change_hook)

OTAMixin

tolomeo.plugins.ota.plugin.OTAMixin

Mixin providing OTA (Over-The-Air) update lifecycle management.

Uses ABCMeta so install_update and monitor_update are enforced. Both bodies are ..., so an unenforced marker means a plugin that forgot one instantiates cleanly and silently no-ops the firmware install.

Source code in tolomeo/plugins/ota/plugin.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
class OTAMixin(metaclass=ABCMeta):
    """Mixin providing OTA (Over-The-Air) update lifecycle management.

    Uses `ABCMeta` so ``install_update`` and ``monitor_update`` are enforced. Both
    bodies are ``...``, so an unenforced marker means a plugin that forgot one
    instantiates cleanly and silently no-ops the firmware install.

    """

    _ota_fsm: OTAStateMachine
    _logger: logging.Logger
    _task_manager: TaskManager
    _push_data_cb: Callable | None
    _download_task_name: str = DEFAULT_DOWNLOAD_TASK_NAME

    def __init__(self, logger: logging.Logger | None = None) -> None:
        """Create the OTA state machine; the logger and callbacks belong to `PluginBase`.

        Args:
            logger: Optional logger for the state machine. If None, it uses its
                own module logger.

        """
        self._ota_fsm = OTAStateMachine(logger=logger, defer_bootstrap=True)

    # --- Properties ---

    @property
    def state(self) -> str:
        """Get the current OTA state.

        Returns:
            The string representation of the current OTA state
            (e.g., "downloading", "paused", "update_ready").

        """
        return str(self._ota_fsm.current_state.value)

    @property
    def fsm(self) -> OTAStateMachine:
        """Get the OTA state machine instance."""
        return self._ota_fsm

    # --- Supplied by the Host Class ---

    @property
    def options(self) -> OTAOptions:
        """Declared, not implemented: `PluginBase` provides this.

        Narrowed to `OTAOptions` here so the mixin's own reads are typed. The
        host class resolves it against whatever model it declares — `OTAOptions`
        or an extension of it.

        """
        raise NotImplementedError("OTAMixin must be combined with PluginBase, which supplies options")

    # --- Abstract Methods ---

    @abstractmethod
    async def install_update(self) -> None:
        """Install the downloaded OTA update.

        This method must be implemented by subclasses to perform the
        actual update process (e.g., applying firmware, rebooting,
        or verifying installation integrity).

        """
        ...

    @abstractmethod
    async def monitor_update(self) -> None:
        """Monitor the OTA update progress.

        This method must be implemented by subclasses to periodically
        check update progress, collect metrics, or detect installation
        anomalies.

        """
        ...

    # --- Protected State Transition Methods ---

    async def _bootstrap(self) -> None:
        """Bootstrap the OTA state machine to the initial state."""
        self._ota_fsm.reset()
        self._logger.info("State machine bootstrapped")

    # --- Task Manager Callbacks ---

    def _on_download_error(self, task_name: str, exception: Exception) -> None:
        """Handle errors raised during download task execution.

        Args:
            task_name: The name of the download task that failed
            exception: The exception raised during execution

        """
        self._logger.error("Download task %s failed: %s", task_name, exception)
        self._ota_fsm.download_fail()

    def _on_download_state_change(self, task_name: str, old_state: str, new_state: str) -> None:
        """Handle state changes from the task manager for the download task.

        Synchronizes the OTA state machine with the task manager's internal
        task state transitions.

        Args:
            task_name: The name of the download task
            old_state: The previous task state
            new_state: The new task state after transition

        """
        self._logger.info("Download task '%s' state changed: %s -> %s", task_name, old_state, new_state)

        if new_state == "Completed":
            self._ota_fsm.download_success()
        elif new_state == "Failed":
            self._ota_fsm.download_fail()
        elif new_state == "Paused":
            if self.state == "downloading":
                self._ota_fsm.pause()
        # Handle resume after pause or power interruption
        elif new_state == "Running" and old_state in {"Pending", "Paused"}:
            if self.state == "paused":
                self._ota_fsm.resume()

    # --- Public Methods ---

    # --- Notification Phase ---
    async def notify_update(
        self, fw_name: str, fw_version: str, url: str | None = None, dest: Path | str | None = None
    ) -> bool:
        """Notify the system that an update is available.

        Sets the pending update information and transitions the OTA state
        machine to `update_available`, unless ``fw_version`` would roll the
        installed firmware back: an older version is ignored, and so is the same
        version unless `OTAOptions.allow_same_version` is set. An ignored update
        leaves both the state and the pending update untouched.

        Args:
            fw_name: Name of the firmware update
            fw_version: Version of the firmware update, in Semantic Versioning 2.0.0
            url: Optional URL for the update package
            dest: Optional destination path for the download

        Returns:
            True if the update was accepted, False if it was ignored.

        Raises:
            ValueError: If ``fw_version`` is not a valid Semantic Versioning 2.0.0 string

        """
        if not self._ota_fsm.accepts_version(fw_version):
            self._logger.warning(
                "Ignoring update %s %s: installed firmware is %s",
                fw_name,
                fw_version,
                self._ota_fsm.current_firmware.version,
            )
            return False

        # Set pending update information
        self._ota_fsm.set_pending_update(
            name=fw_name, version=fw_version, url=url, download_path=dest if dest else self.options.download_dest
        )

        self._logger.info("Update available: %s %s from %s", fw_name, fw_version, url or "unspecified URL")

        self._ota_fsm.notify()
        self._logger.info("Update notification processed")
        return True

    # --- Download Phase ---

    async def start_download(self, url: str | None = None, dest: Path | str | None = None) -> None:
        """Start downloading the OTA update package.

        This method coordinates with the `TaskManager` to create and start
        a download task for the specified URL. It automatically manages
        existing tasks, retries, and OTA state synchronization.

        Args:
            url: Optional download URL. If None, uses the pending update URL
            dest: Optional destination path for saving the downloaded file

        Raises:
            ValueError: If the download URL is missing
            RuntimeError: If the task manager is not initialized
            Exception: If task creation or execution fails

        """
        # Update pending update info if provided
        if url or dest:
            self._ota_fsm.set_pending_update(url=url, download_path=dest)

        # Validate download URL
        download_url = self._ota_fsm.pending_update.url
        if not download_url:
            self._logger.error("Cannot start download: URL not provided")
            raise ValueError("Download URL is required")

        # Validate/set download destination
        download_path = self._ota_fsm.pending_update.download_path
        if not download_path:
            self._ota_fsm.set_pending_update(download_path=self.options.download_dest)
            download_path = str(self.options.download_dest)
            self._logger.info("Using default download destination: %s", download_path)

        # Clean up existing download task if present
        await self._ensure_clean_download_task()

        # Transition to downloading state
        self._ota_fsm.download()
        self._logger.info("Download state transition initiated")

        # Create and start download task
        try:
            await self._create_download_task(download_url, Path(download_path))
        except Exception as e:
            self._logger.error("Failed to start download: %s", e)
            self._ota_fsm.download_fail()
            raise

    async def pause_download(self) -> None:
        """Pause the ongoing OTA download.

        Requests the `TaskManager` to pause the active download task.
        The OTA state machine will automatically transition to `paused`
        through the state change callback.

        Raises:
            RuntimeError: If the task manager is not initialized
            Exception: If the pause operation fails

        """
        try:
            if self._download_task_name not in self._task_manager.tasks:
                self._logger.warning("No active download task to pause")
                return

            task_state = self._task_manager.task_state(self._download_task_name)
            if task_state != "Running":
                self._logger.warning("Download task is not running (state: %s)", task_state)
                return

            await self._task_manager.pause_task(self._download_task_name)
            self._logger.info("Download pause requested")

        except Exception as e:
            self._logger.error("Failed to pause download: %s", e)
            raise

    async def resume_download(self) -> None:
        """Resume a previously paused OTA download.

        Requests the `TaskManager` to resume the paused download task.
        If no task exists, creates a new one using the pending update info.
        The OTA state machine will transition to `downloading` through
        the state change callback.

        Raises:
            RuntimeError: If the task manager is not initialized
            ValueError: If download URL is missing
            Exception: If the resume operation fails

        """
        try:
            # If no task exists, create a new one
            if self._download_task_name not in self._task_manager.tasks:
                self._logger.warning("No download task to resume, creating new task")

                download_url = self._ota_fsm.pending_update.url
                if not download_url:
                    self._logger.error("Cannot resume download: URL not available")
                    raise ValueError("Download URL is required")

                download_path = self._ota_fsm.pending_update.download_path
                if not download_path:
                    self._ota_fsm.set_pending_update(download_path=self.options.download_dest)
                    download_path = str(self.options.download_dest)
                    self._logger.info("Using default download destination: %s", download_path)

                await self._create_download_task(download_url, Path(download_path))
                return

            # Resume existing paused task
            task_state = self._task_manager.task_state(self._download_task_name)
            if task_state != "Paused":
                self._logger.warning("Download task is not paused (state: %s)", task_state)
                return

            await self._task_manager.start_task(self._download_task_name)
            self._logger.info("Download resume requested")

        except Exception as e:
            self._logger.error("Failed to resume download: %s", e)
            self._ota_fsm.download_fail()
            raise

    async def abort_update(self) -> None:
        """Abort a failed OTA update and restart from the beginning.

        Cleans up any existing download task or partial file, and
        transitions back to `update_available`.

        Raises:
            Exception: If cleanup or state reset fails

        """
        try:
            await self._cleanup_download()
            self._ota_fsm.abort()
            self._logger.info("Update aborted")
        except Exception as e:
            self._logger.error("Failed to abort update: %s", e)
            raise

    async def cancel_update(self) -> None:
        """Cancel an in-flight update and clean up its partial download.

        Raises:
            Exception: Whatever the cleanup or the transition raised, after
                logging it.

        """
        try:
            await self._cleanup_download()
            self._ota_fsm.cancel()
            self._logger.info("Update cancelled")
        except Exception as e:
            self._logger.error("Failed to cancel update: %s", e)
            raise

    async def clear_update(self) -> None:
        """Discard the pending update and clean up its partial download.

        Raises:
            Exception: Whatever the cleanup or the transition raised, after
                logging it.

        """
        try:
            await self._cleanup_download()
            self._ota_fsm.clear_pending_update()
            self._ota_fsm.clear()
            self._logger.info("Pending update cleared")
        except Exception as e:
            self._logger.error("Failed to clear pending update: %s", e)
            raise

    async def reset_state(self) -> None:
        """Reset the OTA state machine to a clean baseline.

        Cleans up any in-flight download, clears the pending update, removes the
        persisted checkpoint, and drives the FSM through ``reset()`` back to
        ``bootstrapping``. With no checkpoint present, re-detection resolves to
        ``updated``.

        Raises:
            Exception: If cleanup or the reset transition fails.

        """
        try:
            await self._cleanup_download()
            self._ota_fsm.clear_pending_update()
            self._ota_fsm.clear_checkpoint()
            self._ota_fsm.reset()
            self._logger.info("OTA state reset")
        except Exception as e:
            self._logger.error("Failed to reset state: %s", e)
            raise

    # --- System Control ---

    async def reboot_system(self) -> None:
        """Request a system reboot using systemd via D-Bus.

        Attempts to start the `systemd-reboot.service` unit using the
        system D-Bus. This method is typically called after a successful
        update to reboot into the new firmware.

        Raises:
            Exception: If the D-Bus call fails or the service cannot be started

        """
        try:
            bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
            introspection = await bus.introspect("org.freedesktop.systemd1", "/org/freedesktop/systemd1")
            obj = bus.get_proxy_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1", introspection)
            manager = obj.get_interface("org.freedesktop.systemd1.Manager")

            await self._safe_call_method(manager, "call_start_unit", SYSTEMD_REBOOT_SERVICE, "replace")
            self._logger.info("Service '%s' start requested", SYSTEMD_REBOOT_SERVICE)
        except Exception as e:
            self._logger.error("Failed to start service '%s': %s", SYSTEMD_REBOOT_SERVICE, e)
            raise

    # --- Private Helper Methods ---

    async def _ensure_clean_download_task(self) -> None:
        """Ensure no existing download task is present.

        If a download task exists, stops and removes it before proceeding.

        """
        if self._download_task_name in self._task_manager.tasks:
            task_state = self._task_manager.task_state(self._download_task_name)
            self._logger.warning("Download task already exists in state: %s. Stopping and recreating.", task_state)
            await self._task_manager.stop_task(self._download_task_name)
            self._logger.debug("Stopped existing download task")
            await self._task_manager.remove_task(self._download_task_name)
            self._logger.debug("Removed existing download task")

    async def _create_download_task(self, url: str, dest: Path) -> None:
        """Create and start a new download task.

        Args:
            url: The URL to download from
            dest: The destination path for the downloaded file

        Raises:
            Exception: If task creation or start fails

        """
        download_task = DownloadTask(url=url, dest=dest)
        self._logger.debug("Created DownloadTask instance")

        await self._task_manager.add_task(
            name=self._download_task_name,
            coro_func=download_task.run,
            auto_restart=False,
            max_retries=3,
            retry_delay=2.0,
        )
        self._logger.info("Download task started for %s", url)

    async def _cleanup_download(self) -> None:
        """Clean up download task and partial file.

        Stops and removes the download task if it exists, and deletes
        any partial download file.

        Raises:
            Exception: If cleanup operations fail

        """
        if self._download_task_name in self._task_manager.tasks:
            await self._task_manager.stop_task(self._download_task_name)
            await self._task_manager.remove_task(self._download_task_name)
            self._logger.debug("Removed download task")

        download_path = self._ota_fsm.pending_update.download_path
        if download_path:
            dest_path = Path(download_path)
            if dest_path.exists():
                dest_path.unlink()
                self._logger.debug("Removed existing file at: %s", dest_path)

    async def _safe_call_method(self, interface: Any, method_name: str, *args: Any, **kwargs: Any) -> Any:
        """Safely call a D-Bus method with validation and error handling.

        Validates the target method's existence, callability, and coroutine
        nature before invoking it asynchronously.

        Args:
            interface: The D-Bus interface containing the target method
            method_name: The name of the method to call
            *args: Positional arguments to pass to the method
            **kwargs: Keyword arguments to pass to the method

        Returns:
            The return value from the D-Bus method call, or None if validation fails

        Raises:
            Exception: If the method call fails during execution

        """
        method = getattr(interface, method_name, None)
        if method is None:
            self._logger.warning("Method '%s' not found on interface", method_name)
            return
        if not callable(method):
            self._logger.warning("Attribute '%s' is not callable", method_name)
            return
        if not inspect.iscoroutinefunction(method):
            self._logger.warning("Method '%s' is not an async coroutine", method_name)
            return

        return await method(*args, **kwargs)

fsm property

Get the OTA state machine instance.

options property

Declared, not implemented: PluginBase provides this.

Narrowed to OTAOptions here so the mixin's own reads are typed. The host class resolves it against whatever model it declares — OTAOptions or an extension of it.

state property

Get the current OTA state.

Returns:

Type Description
str

The string representation of the current OTA state

str

(e.g., "downloading", "paused", "update_ready").

__init__(logger=None)

Create the OTA state machine; the logger and callbacks belong to PluginBase.

Parameters:

Name Type Description Default
logger Logger | None

Optional logger for the state machine. If None, it uses its own module logger.

None
Source code in tolomeo/plugins/ota/plugin.py
def __init__(self, logger: logging.Logger | None = None) -> None:
    """Create the OTA state machine; the logger and callbacks belong to `PluginBase`.

    Args:
        logger: Optional logger for the state machine. If None, it uses its
            own module logger.

    """
    self._ota_fsm = OTAStateMachine(logger=logger, defer_bootstrap=True)

abort_update() async

Abort a failed OTA update and restart from the beginning.

Cleans up any existing download task or partial file, and transitions back to update_available.

Raises:

Type Description
Exception

If cleanup or state reset fails

Source code in tolomeo/plugins/ota/plugin.py
async def abort_update(self) -> None:
    """Abort a failed OTA update and restart from the beginning.

    Cleans up any existing download task or partial file, and
    transitions back to `update_available`.

    Raises:
        Exception: If cleanup or state reset fails

    """
    try:
        await self._cleanup_download()
        self._ota_fsm.abort()
        self._logger.info("Update aborted")
    except Exception as e:
        self._logger.error("Failed to abort update: %s", e)
        raise

cancel_update() async

Cancel an in-flight update and clean up its partial download.

Raises:

Type Description
Exception

Whatever the cleanup or the transition raised, after logging it.

Source code in tolomeo/plugins/ota/plugin.py
async def cancel_update(self) -> None:
    """Cancel an in-flight update and clean up its partial download.

    Raises:
        Exception: Whatever the cleanup or the transition raised, after
            logging it.

    """
    try:
        await self._cleanup_download()
        self._ota_fsm.cancel()
        self._logger.info("Update cancelled")
    except Exception as e:
        self._logger.error("Failed to cancel update: %s", e)
        raise

clear_update() async

Discard the pending update and clean up its partial download.

Raises:

Type Description
Exception

Whatever the cleanup or the transition raised, after logging it.

Source code in tolomeo/plugins/ota/plugin.py
async def clear_update(self) -> None:
    """Discard the pending update and clean up its partial download.

    Raises:
        Exception: Whatever the cleanup or the transition raised, after
            logging it.

    """
    try:
        await self._cleanup_download()
        self._ota_fsm.clear_pending_update()
        self._ota_fsm.clear()
        self._logger.info("Pending update cleared")
    except Exception as e:
        self._logger.error("Failed to clear pending update: %s", e)
        raise

install_update() abstractmethod async

Install the downloaded OTA update.

This method must be implemented by subclasses to perform the actual update process (e.g., applying firmware, rebooting, or verifying installation integrity).

Source code in tolomeo/plugins/ota/plugin.py
@abstractmethod
async def install_update(self) -> None:
    """Install the downloaded OTA update.

    This method must be implemented by subclasses to perform the
    actual update process (e.g., applying firmware, rebooting,
    or verifying installation integrity).

    """
    ...

monitor_update() abstractmethod async

Monitor the OTA update progress.

This method must be implemented by subclasses to periodically check update progress, collect metrics, or detect installation anomalies.

Source code in tolomeo/plugins/ota/plugin.py
@abstractmethod
async def monitor_update(self) -> None:
    """Monitor the OTA update progress.

    This method must be implemented by subclasses to periodically
    check update progress, collect metrics, or detect installation
    anomalies.

    """
    ...

notify_update(fw_name, fw_version, url=None, dest=None) async

Notify the system that an update is available.

Sets the pending update information and transitions the OTA state machine to update_available, unless fw_version would roll the installed firmware back: an older version is ignored, and so is the same version unless OTAOptions.allow_same_version is set. An ignored update leaves both the state and the pending update untouched.

Parameters:

Name Type Description Default
fw_name str

Name of the firmware update

required
fw_version str

Version of the firmware update, in Semantic Versioning 2.0.0

required
url str | None

Optional URL for the update package

None
dest Path | str | None

Optional destination path for the download

None

Returns:

Type Description
bool

True if the update was accepted, False if it was ignored.

Raises:

Type Description
ValueError

If fw_version is not a valid Semantic Versioning 2.0.0 string

Source code in tolomeo/plugins/ota/plugin.py
async def notify_update(
    self, fw_name: str, fw_version: str, url: str | None = None, dest: Path | str | None = None
) -> bool:
    """Notify the system that an update is available.

    Sets the pending update information and transitions the OTA state
    machine to `update_available`, unless ``fw_version`` would roll the
    installed firmware back: an older version is ignored, and so is the same
    version unless `OTAOptions.allow_same_version` is set. An ignored update
    leaves both the state and the pending update untouched.

    Args:
        fw_name: Name of the firmware update
        fw_version: Version of the firmware update, in Semantic Versioning 2.0.0
        url: Optional URL for the update package
        dest: Optional destination path for the download

    Returns:
        True if the update was accepted, False if it was ignored.

    Raises:
        ValueError: If ``fw_version`` is not a valid Semantic Versioning 2.0.0 string

    """
    if not self._ota_fsm.accepts_version(fw_version):
        self._logger.warning(
            "Ignoring update %s %s: installed firmware is %s",
            fw_name,
            fw_version,
            self._ota_fsm.current_firmware.version,
        )
        return False

    # Set pending update information
    self._ota_fsm.set_pending_update(
        name=fw_name, version=fw_version, url=url, download_path=dest if dest else self.options.download_dest
    )

    self._logger.info("Update available: %s %s from %s", fw_name, fw_version, url or "unspecified URL")

    self._ota_fsm.notify()
    self._logger.info("Update notification processed")
    return True

pause_download() async

Pause the ongoing OTA download.

Requests the TaskManager to pause the active download task. The OTA state machine will automatically transition to paused through the state change callback.

Raises:

Type Description
RuntimeError

If the task manager is not initialized

Exception

If the pause operation fails

Source code in tolomeo/plugins/ota/plugin.py
async def pause_download(self) -> None:
    """Pause the ongoing OTA download.

    Requests the `TaskManager` to pause the active download task.
    The OTA state machine will automatically transition to `paused`
    through the state change callback.

    Raises:
        RuntimeError: If the task manager is not initialized
        Exception: If the pause operation fails

    """
    try:
        if self._download_task_name not in self._task_manager.tasks:
            self._logger.warning("No active download task to pause")
            return

        task_state = self._task_manager.task_state(self._download_task_name)
        if task_state != "Running":
            self._logger.warning("Download task is not running (state: %s)", task_state)
            return

        await self._task_manager.pause_task(self._download_task_name)
        self._logger.info("Download pause requested")

    except Exception as e:
        self._logger.error("Failed to pause download: %s", e)
        raise

reboot_system() async

Request a system reboot using systemd via D-Bus.

Attempts to start the systemd-reboot.service unit using the system D-Bus. This method is typically called after a successful update to reboot into the new firmware.

Raises:

Type Description
Exception

If the D-Bus call fails or the service cannot be started

Source code in tolomeo/plugins/ota/plugin.py
async def reboot_system(self) -> None:
    """Request a system reboot using systemd via D-Bus.

    Attempts to start the `systemd-reboot.service` unit using the
    system D-Bus. This method is typically called after a successful
    update to reboot into the new firmware.

    Raises:
        Exception: If the D-Bus call fails or the service cannot be started

    """
    try:
        bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
        introspection = await bus.introspect("org.freedesktop.systemd1", "/org/freedesktop/systemd1")
        obj = bus.get_proxy_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1", introspection)
        manager = obj.get_interface("org.freedesktop.systemd1.Manager")

        await self._safe_call_method(manager, "call_start_unit", SYSTEMD_REBOOT_SERVICE, "replace")
        self._logger.info("Service '%s' start requested", SYSTEMD_REBOOT_SERVICE)
    except Exception as e:
        self._logger.error("Failed to start service '%s': %s", SYSTEMD_REBOOT_SERVICE, e)
        raise

reset_state() async

Reset the OTA state machine to a clean baseline.

Cleans up any in-flight download, clears the pending update, removes the persisted checkpoint, and drives the FSM through reset() back to bootstrapping. With no checkpoint present, re-detection resolves to updated.

Raises:

Type Description
Exception

If cleanup or the reset transition fails.

Source code in tolomeo/plugins/ota/plugin.py
async def reset_state(self) -> None:
    """Reset the OTA state machine to a clean baseline.

    Cleans up any in-flight download, clears the pending update, removes the
    persisted checkpoint, and drives the FSM through ``reset()`` back to
    ``bootstrapping``. With no checkpoint present, re-detection resolves to
    ``updated``.

    Raises:
        Exception: If cleanup or the reset transition fails.

    """
    try:
        await self._cleanup_download()
        self._ota_fsm.clear_pending_update()
        self._ota_fsm.clear_checkpoint()
        self._ota_fsm.reset()
        self._logger.info("OTA state reset")
    except Exception as e:
        self._logger.error("Failed to reset state: %s", e)
        raise

resume_download() async

Resume a previously paused OTA download.

Requests the TaskManager to resume the paused download task. If no task exists, creates a new one using the pending update info. The OTA state machine will transition to downloading through the state change callback.

Raises:

Type Description
RuntimeError

If the task manager is not initialized

ValueError

If download URL is missing

Exception

If the resume operation fails

Source code in tolomeo/plugins/ota/plugin.py
async def resume_download(self) -> None:
    """Resume a previously paused OTA download.

    Requests the `TaskManager` to resume the paused download task.
    If no task exists, creates a new one using the pending update info.
    The OTA state machine will transition to `downloading` through
    the state change callback.

    Raises:
        RuntimeError: If the task manager is not initialized
        ValueError: If download URL is missing
        Exception: If the resume operation fails

    """
    try:
        # If no task exists, create a new one
        if self._download_task_name not in self._task_manager.tasks:
            self._logger.warning("No download task to resume, creating new task")

            download_url = self._ota_fsm.pending_update.url
            if not download_url:
                self._logger.error("Cannot resume download: URL not available")
                raise ValueError("Download URL is required")

            download_path = self._ota_fsm.pending_update.download_path
            if not download_path:
                self._ota_fsm.set_pending_update(download_path=self.options.download_dest)
                download_path = str(self.options.download_dest)
                self._logger.info("Using default download destination: %s", download_path)

            await self._create_download_task(download_url, Path(download_path))
            return

        # Resume existing paused task
        task_state = self._task_manager.task_state(self._download_task_name)
        if task_state != "Paused":
            self._logger.warning("Download task is not paused (state: %s)", task_state)
            return

        await self._task_manager.start_task(self._download_task_name)
        self._logger.info("Download resume requested")

    except Exception as e:
        self._logger.error("Failed to resume download: %s", e)
        self._ota_fsm.download_fail()
        raise

start_download(url=None, dest=None) async

Start downloading the OTA update package.

This method coordinates with the TaskManager to create and start a download task for the specified URL. It automatically manages existing tasks, retries, and OTA state synchronization.

Parameters:

Name Type Description Default
url str | None

Optional download URL. If None, uses the pending update URL

None
dest Path | str | None

Optional destination path for saving the downloaded file

None

Raises:

Type Description
ValueError

If the download URL is missing

RuntimeError

If the task manager is not initialized

Exception

If task creation or execution fails

Source code in tolomeo/plugins/ota/plugin.py
async def start_download(self, url: str | None = None, dest: Path | str | None = None) -> None:
    """Start downloading the OTA update package.

    This method coordinates with the `TaskManager` to create and start
    a download task for the specified URL. It automatically manages
    existing tasks, retries, and OTA state synchronization.

    Args:
        url: Optional download URL. If None, uses the pending update URL
        dest: Optional destination path for saving the downloaded file

    Raises:
        ValueError: If the download URL is missing
        RuntimeError: If the task manager is not initialized
        Exception: If task creation or execution fails

    """
    # Update pending update info if provided
    if url or dest:
        self._ota_fsm.set_pending_update(url=url, download_path=dest)

    # Validate download URL
    download_url = self._ota_fsm.pending_update.url
    if not download_url:
        self._logger.error("Cannot start download: URL not provided")
        raise ValueError("Download URL is required")

    # Validate/set download destination
    download_path = self._ota_fsm.pending_update.download_path
    if not download_path:
        self._ota_fsm.set_pending_update(download_path=self.options.download_dest)
        download_path = str(self.options.download_dest)
        self._logger.info("Using default download destination: %s", download_path)

    # Clean up existing download task if present
    await self._ensure_clean_download_task()

    # Transition to downloading state
    self._ota_fsm.download()
    self._logger.info("Download state transition initiated")

    # Create and start download task
    try:
        await self._create_download_task(download_url, Path(download_path))
    except Exception as e:
        self._logger.error("Failed to start download: %s", e)
        self._ota_fsm.download_fail()
        raise

OTAOptions

tolomeo.plugins.ota.models.OTAOptions

Bases: BaseModel

Deployment options for OTAPlugin, read from [plugins.<id>].

Declared as OTAPlugin's Meta.config_model. A plugin deriving from OTAPlugin adds its own options by extending this model, so the fields OTAPlugin itself reads stay present::

class SWUpdateOptions(OTAOptions):
    socket_path: Path = Path("/run/swupdate.sock")

extra="forbid" turns a mistyped key into a startup error naming it. hide_input_in_errors=True keeps the offending value out of that error, exactly as tolomeo.config.Config does: the error reaches startup logs, and an option may hold a secret.

Attributes:

Name Type Description
download_dir Path

Directory the update package is written to.

download_filename str

Name the package is saved under.

checkpoint_file Path

Where the OTA state snapshot is persisted, so an interrupted update is recoverable across a restart. Defaults under DEFAULT_DOWNLOAD_DIR, not under a configured download_dir.

allow_same_version bool

Whether an update whose version equals the installed firmware's is accepted, whether notified or restored from the checkpoint. An older version never is.

Source code in tolomeo/plugins/ota/models.py
class OTAOptions(BaseModel):
    """Deployment options for `OTAPlugin`, read from ``[plugins.<id>]``.

    Declared as `OTAPlugin`'s ``Meta.config_model``. A plugin deriving from
    `OTAPlugin` adds its own options by **extending** this model, so the fields
    `OTAPlugin` itself reads stay present::

        class SWUpdateOptions(OTAOptions):
            socket_path: Path = Path("/run/swupdate.sock")

    ``extra="forbid"`` turns a mistyped key into a startup error naming it.
    ``hide_input_in_errors=True`` keeps the offending *value* out of that error,
    exactly as `tolomeo.config.Config` does: the error reaches startup logs, and
    an option may hold a secret.

    Attributes:
        download_dir: Directory the update package is written to.
        download_filename: Name the package is saved under.
        checkpoint_file: Where the OTA state snapshot is persisted, so an
            interrupted update is recoverable across a restart. Defaults under
            `DEFAULT_DOWNLOAD_DIR`, *not* under a configured ``download_dir``.
        allow_same_version: Whether an update whose version equals the installed
            firmware's is accepted, whether notified or restored from the
            checkpoint. An older version never is.

    """

    model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True)

    download_dir: Path = DEFAULT_DOWNLOAD_DIR
    download_filename: str = DEFAULT_DOWNLOAD_FILENAME
    checkpoint_file: Path = DEFAULT_CHECKPOINT_FILE
    allow_same_version: bool = False

    # --- Properties ---

    @property
    def download_dest(self) -> Path:
        """Full path the update package is downloaded to."""
        return self.download_dir / self.download_filename

download_dest property

Full path the update package is downloaded to.


OTAStateMachine

tolomeo.plugins.ota.fsm.OTAStateMachine

Bases: StateMachine

State machine managing the OTA (Over-The-Air) update process.

This class encapsulates the state transitions for OTA updates, including update detection, download, installation, and failure recovery. It operates independently from any higher-level management logic and communicates state changes via a push callback.

Source code in tolomeo/plugins/ota/fsm.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
class OTAStateMachine(StateMachine):
    """State machine managing the OTA (Over-The-Air) update process.

    This class encapsulates the state transitions for OTA updates, including
    update detection, download, installation, and failure recovery. It operates
    independently from any higher-level management logic and communicates state
    changes via a push callback.

    """

    # --- States ---
    bootstrapping = State("Bootstrapping", initial=True, value="bootstrapping")

    updated = State("Updated", value="updated")
    update_available = State("UpdateAvailable", value="update_available")
    update_ready = State("UpdateReady", value="update_ready")
    paused = State("Paused", value="paused")

    downloading = State("Downloading", value="downloading")
    download_failed = State("DownloadFailed", value="download_failed")

    updating = State("Updating", value="updating")
    update_failed = State("UpdateFailed", value="update_failed")
    update_successful = State("UpdateSuccessful", final=True, value="update_successful")

    # --- Initial resolution (bootstrapping) ---
    detect_up_to_date = bootstrapping.to(updated)
    detect_update_available = bootstrapping.to(update_available)
    detect_download_paused = bootstrapping.to(paused)
    detect_update_ready = bootstrapping.to(update_ready)
    detect_download_error = bootstrapping.to(download_failed)
    detect_update_error = bootstrapping.to(update_failed)

    # --- Notification phase ---
    notify = updated.to(update_available) | update_available.to(update_available)

    # --- Download phase ---
    download = update_available.to(downloading) | download_failed.to(downloading)
    pause = downloading.to(paused)
    resume = paused.to(downloading)

    download_fail = downloading.to(download_failed)
    download_success = downloading.to(update_ready)

    # --- Installation phase ---
    install = update_ready.to(updating) | update_failed.to(updating)
    update_fail = updating.to(update_failed)
    update_success = updating.to(update_successful)

    # --- Cancellation ---
    abort = download_failed.to(update_available) | update_failed.to(update_available)
    clear = update_available.to(updated)
    cancel = downloading.to(update_available) | paused.to(update_available) | update_ready.to(update_available)
    reset = (
        bootstrapping.to(bootstrapping)
        | updated.to(bootstrapping)
        | update_available.to(bootstrapping)
        | update_ready.to(bootstrapping)
        | update_failed.to(bootstrapping)
        | downloading.to(bootstrapping)
        | download_failed.to(bootstrapping)
        | paused.to(bootstrapping)
        | updating.to(bootstrapping)
    )

    def __init__(
        self,
        logger: logging.Logger | None = None,
        checkpoint_file: Path | str | None = None,
        *,
        defer_bootstrap: bool = False,
        allow_same_version: bool = False,
    ):
        """Initialize the OTA state machine.

        Args:
            logger: Optional logger instance for state transitions and diagnostic
                output. If None, a default logger is created.
            checkpoint_file: Path to the checkpoint file. If None, uses
                `DEFAULT_CHECKPOINT_FILE`.
            defer_bootstrap: When ``True``, do not bootstrap on construction.
                Entering the initial ``bootstrapping`` state reads the checkpoint
                and, if one is found, transitions into its state — which writes
                another. An owner that does not yet know where its checkpoint
                lives passes ``True``, assigns ``checkpoint_file`` once it does,
                and then triggers ``reset``. `OTAMixin` does exactly that: the
                machine is built in its ``__init__``, before ``connect`` has
                assigned the plugin id its configuration table is keyed by.
            allow_same_version: Whether an update to the installed version is
                accepted, see `accepts_version`. Assignable later, like
                ``checkpoint_file``, before bootstrapping.

        """
        self._push_callback: Callable | None = None
        self._logger = logger or logging.getLogger(__name__)
        self._checkpoint_file: Path = Path(checkpoint_file) if checkpoint_file else DEFAULT_CHECKPOINT_FILE

        # Set before super().__init__(), which enters `bootstrapping`.
        self._checkpoint_resolved: bool = not defer_bootstrap

        self.allow_same_version: bool = allow_same_version

        # Load current firmware info from system
        self._current_firmware: InstalledFirmware = InstalledFirmware.load()

        # Initialize pending update (empty until an update is available)
        self._pending_update: PendingUpdate = PendingUpdate()

        super().__init__()

    # --- Properties ---

    @property
    def current_firmware(self) -> InstalledFirmware:
        """Get the currently installed firmware info."""
        return self._current_firmware

    @property
    def pending_update(self) -> PendingUpdate:
        """Get the pending update info."""
        return self._pending_update

    @property
    def checkpoint_file(self) -> Path:
        """Get where the checkpoint currently lives."""
        return self._checkpoint_file

    @checkpoint_file.setter
    def checkpoint_file(self, path: Path | str) -> None:
        """Set where the checkpoint lives, marking the location final.

        Assigning this arms bootstrapping: the next entry into ``bootstrapping``
        reads from ``path``. `OTAPlugin` assigns it from configuration in
        ``before_setup``, and ``after_setup`` then bootstraps.

        """
        self._checkpoint_file = Path(path)
        self._checkpoint_resolved = True

    def accepts_version(self, version: str) -> bool:
        """Tell whether an update to ``version`` would not roll the firmware back.

        A newer version than the installed firmware's is accepted, an older one
        never is, and the same one only when `allow_same_version` is set.
        Versions compare by Semantic Versioning precedence, so build metadata
        is ignored.

        Args:
            version: Version of the update, in Semantic Versioning 2.0.0

        Returns:
            True if the update may proceed, False if it must be ignored.

        Raises:
            ValueError: If ``version`` or the installed firmware's version is
                not a valid Semantic Versioning 2.0.0 string

        """
        candidate = SemanticVersion.parse(version)
        installed = SemanticVersion.parse(self._current_firmware.version)
        return candidate > installed or (candidate == installed and self.allow_same_version)

    def set_pending_update(
        self,
        name: str | None = None,
        version: str | None = None,
        url: str | None = None,
        download_path: str | Path | None = None,
    ) -> None:
        """Set or update the pending update information.

        Args:
            name: Name of the firmware update
            version: Version of the firmware update
            url: URL from which to download the update
            download_path: Path where the update will be saved

        """
        if name is not None:
            self._pending_update.name = name
        if version is not None:
            self._pending_update.version = version
        if url is not None:
            self._pending_update.url = url
        if download_path is not None:
            self._pending_update.download_path = str(download_path)

    def clear_pending_update(self) -> None:
        """Clear the pending update information."""
        self._pending_update = PendingUpdate()

    def clear_checkpoint(self) -> None:
        """Remove the persisted checkpoint file, if present."""
        self._checkpoint_file.unlink(missing_ok=True)

    @property
    def push_callback(self) -> Callable | None:
        """Get the registered push callback."""
        return self._push_callback

    @push_callback.setter
    def push_callback(self, callback: Callable | None) -> None:
        """Set or update the push callback.

        The push callback is invoked asynchronously whenever the OTA state
        machine transitions to a new state, allowing external components to
        receive real-time update notifications.

        Args:
            callback: The callback function to register. If None, any previously
                registered callback is removed.

        """
        self._push_callback = callback

    # --- Event Handlers ---

    def on_transition(self, event: str, source: State, target: State, **kwargs: Any) -> None:
        """Handle actions after each state transition.

        This method is automatically called by the underlying state machine
        whenever a transition occurs, *before* ``current_state`` is updated to
        ``target``. It logs the transition and, if the target state is one
        that can be restored from a checkpoint, saves the current state.
        Pushing the updated state is handled separately by
        `on_enter_state`, which the engine calls after the assignment — at
        this point `current_state` still reports the *source* state, so a
        push from here would announce the wrong value.

        Args:
            event: The name of the triggering event (e.g., 'download', 'install')
            source: The state from which the transition originated
            target: The new state after the transition
            **kwargs: Additional keyword arguments for transition hooks

        """
        self._logger.info("OTA STATE: Transitioned from %s to %s via '%s'", source.id, target.id, event)

        # States that can be restored from checkpoint
        checkpointable_states: Set = {
            self.update_available.value,
            self.downloading.value,
            self.paused.value,
            self.update_ready.value,
            self.download_failed.value,
            self.update_failed.value,
        }

        # Save checkpoint if transitioning to a checkpointable state
        if target.value in checkpointable_states:
            self._logger.info("Saving checkpoint in state: %s", target.value)
            try:
                snapshot = OTASnapshot(
                    state=target.value,
                    current_firmware=self._current_firmware,
                    pending_update=self._pending_update,
                )
                snapshot.save(self._checkpoint_file)
            except (OSError, IOError) as e:
                self._logger.error("Failed to save checkpoint to %s: %s", self._checkpoint_file, e)
            except Exception as e:
                self._logger.exception("Unexpected error while saving checkpoint: %s", e)

        if target.value in {self.updated.value, self.update_successful.value}:
            self._checkpoint_file.unlink(missing_ok=True)

    def on_enter_state(self, event: str, source: State, target: State, **kwargs: Any) -> None:
        """Announce every state except the transient trip into `bootstrapping`.

        Runs once per transition, after ``current_state`` has been updated to
        ``target`` — unlike `on_transition`, which the underlying engine calls
        *before* that assignment. Keying the guard on the target rather than the
        source matters at startup: the state a restarted service resolves to is
        reached *from* bootstrapping, and suppressing that left consumers with
        no way to learn it short of asking ``GetOTAStatus``.

        Args:
            event: The name of the triggering event.
            source: The state from which the transition originated.
            target: The new state after the transition.
            **kwargs: Additional keyword arguments for transition hooks.

        """
        if target.value != self.bootstrapping.value:
            self._schedule_push()

    def _schedule_push(self) -> None:
        """Push the current state in the background, if there is a loop for it.

        Resolution can happen synchronously — constructing the machine without
        ``defer_bootstrap`` fires a ``detect_*`` transition right away — and that
        caller may not be running under asyncio. Skip the push rather than
        raising in the middle of a state transition.

        """
        try:
            asyncio.get_running_loop()
        except RuntimeError:
            self._logger.debug("No running event loop; not pushing status %s", self.current_state.value)
            return

        asyncio.create_task(self._push_data())

    def _detect_checkpointed_state(self, state: str) -> None:
        """Fire the ``detect_*`` transition matching a checkpointed state name.

        Args:
            state: The ``state`` field read from the snapshot. An unrecognised
                value is treated as up to date, so a checkpoint written by a
                newer version cannot strand the machine in ``bootstrapping``.

        """
        match state:
            case "updated" | "update_successful":
                self.detect_up_to_date()
            case "update_available":
                self.detect_update_available()
            case "downloading" | "paused":
                self.detect_download_paused()
            case "update_ready":
                self.detect_update_ready()
            case "download_failed":
                self.detect_download_error()
            case "update_failed":
                self.detect_update_error()
            case _:
                self._logger.warning("Unknown checkpointed state: %s, defaulting to updated", state)
                self.detect_up_to_date()

    def on_enter_bootstrapping(self) -> None:
        """Handle the entry into the 'Bootstrapping' state.

        This method is called when the state machine transitions to the
        'Bootstrapping' state. It loads the saved checkpoint (if available),
        restores the firmware info, and transitions to the last checkpointed state.

        A checkpointed update that `accepts_version` rejects is dropped, and the
        machine resolves to ``updated``: the firmware may have been updated by
        other means while the checkpoint sat on disk.

        A deferred machine has not yet been told where its checkpoint lives, so
        this returns without reading anything until `checkpoint_file` is assigned.

        """
        if not self._checkpoint_resolved:
            self._logger.debug("Checkpoint location not resolved yet; deferring bootstrap")
            return

        if not self._checkpoint_file.exists():
            self._logger.info("No checkpoint found at %s, defaulting to updated", self._checkpoint_file)
            self.detect_up_to_date()
            return

        try:
            self._logger.info("Loading from saved checkpoint: %s", self._checkpoint_file)
            snapshot = OTASnapshot.load(self._checkpoint_file)

            pending = snapshot.pending_update
            if not self.accepts_version(pending.version or ""):
                self._logger.warning(
                    "Dropping checkpointed update %s %s: installed firmware is %s",
                    pending.name,
                    pending.version,
                    self._current_firmware.version,
                )
                self.detect_up_to_date()
                return

            # Restore pending update info from checkpoint
            self._pending_update = snapshot.pending_update
            self._logger.info(
                "Restored pending update:  %s %s", self._pending_update.name, self._pending_update.version
            )

            # Transition to the checkpointed state
            self._detect_checkpointed_state(snapshot.state)

        except FileNotFoundError:
            self._logger.error("Checkpoint file not found at %s, defaulting to updated", self._checkpoint_file)
            self.detect_up_to_date()
        except (yaml.YAMLError, KeyError, ValueError) as e:
            self._logger.error("Failed to parse checkpoint file: %s. Defaulting to updated", e)
            self.detect_up_to_date()
        except PermissionError as e:
            self._logger.error("Permission denied reading checkpoint file %s: %s", self._checkpoint_file, e)
            self.detect_up_to_date()
        except Exception:
            self._logger.exception("Unexpected error loading checkpoint from %s", self._checkpoint_file)
            self.detect_up_to_date()

    def on_exit_bootstrapping(self) -> None:
        """Handle exit from bootstrapping state."""

    # --- Internal Methods ---

    async def _push_data(self) -> None:
        """Broadcast the current OTA state via the registered push callback.

        Constructs a payload containing the firmware version, OTA status, and
        a timestamp, then invokes the push callback asynchronously to send
        this data to interested consumers.

        The payload takes the shape::

            {
                "fwName": "firmware",
                "fwVersion": "1.2.3",
                "otaStatus": "downloading",
                "pendingFwName": "new_firmware",
                "pendingFwVersion": "1.3.0",
                "timestamp": 1728392011
            }

        """
        timestamp: int = round(time.time())
        state_value = self.current_state.value

        self._logger.info("OTA STATE: Status set to %s", state_value)

        data: Dict = {
            "otaStatus": state_value,
            "fwName": self._current_firmware.name,
            "fwVersion": self._current_firmware.version,
            "timestamp": timestamp,
        }

        # Add pending update info if available
        if self._pending_update.name and self._pending_update.name != "N/A":
            data["pendingFwName"] = self._pending_update.name
        if self._pending_update.version and self._pending_update.version != "N/A":
            data["pendingFwVersion"] = self._pending_update.version

        self._logger.debug("OTA STATE: Preparing to push: %s", data)

        if callable(self._push_callback):
            self._logger.debug("OTA STATE: Invoking push_callback")
            await self._push_callback(data)
        else:
            self._logger.warning("No push callback registered — skipping push.")

checkpoint_file property writable

Get where the checkpoint currently lives.

current_firmware property

Get the currently installed firmware info.

pending_update property

Get the pending update info.

push_callback property writable

Get the registered push callback.

__init__(logger=None, checkpoint_file=None, *, defer_bootstrap=False, allow_same_version=False)

Initialize the OTA state machine.

Parameters:

Name Type Description Default
logger Logger | None

Optional logger instance for state transitions and diagnostic output. If None, a default logger is created.

None
checkpoint_file Path | str | None

Path to the checkpoint file. If None, uses DEFAULT_CHECKPOINT_FILE.

None
defer_bootstrap bool

When True, do not bootstrap on construction. Entering the initial bootstrapping state reads the checkpoint and, if one is found, transitions into its state — which writes another. An owner that does not yet know where its checkpoint lives passes True, assigns checkpoint_file once it does, and then triggers reset. OTAMixin does exactly that: the machine is built in its __init__, before connect has assigned the plugin id its configuration table is keyed by.

False
allow_same_version bool

Whether an update to the installed version is accepted, see accepts_version. Assignable later, like checkpoint_file, before bootstrapping.

False
Source code in tolomeo/plugins/ota/fsm.py
def __init__(
    self,
    logger: logging.Logger | None = None,
    checkpoint_file: Path | str | None = None,
    *,
    defer_bootstrap: bool = False,
    allow_same_version: bool = False,
):
    """Initialize the OTA state machine.

    Args:
        logger: Optional logger instance for state transitions and diagnostic
            output. If None, a default logger is created.
        checkpoint_file: Path to the checkpoint file. If None, uses
            `DEFAULT_CHECKPOINT_FILE`.
        defer_bootstrap: When ``True``, do not bootstrap on construction.
            Entering the initial ``bootstrapping`` state reads the checkpoint
            and, if one is found, transitions into its state — which writes
            another. An owner that does not yet know where its checkpoint
            lives passes ``True``, assigns ``checkpoint_file`` once it does,
            and then triggers ``reset``. `OTAMixin` does exactly that: the
            machine is built in its ``__init__``, before ``connect`` has
            assigned the plugin id its configuration table is keyed by.
        allow_same_version: Whether an update to the installed version is
            accepted, see `accepts_version`. Assignable later, like
            ``checkpoint_file``, before bootstrapping.

    """
    self._push_callback: Callable | None = None
    self._logger = logger or logging.getLogger(__name__)
    self._checkpoint_file: Path = Path(checkpoint_file) if checkpoint_file else DEFAULT_CHECKPOINT_FILE

    # Set before super().__init__(), which enters `bootstrapping`.
    self._checkpoint_resolved: bool = not defer_bootstrap

    self.allow_same_version: bool = allow_same_version

    # Load current firmware info from system
    self._current_firmware: InstalledFirmware = InstalledFirmware.load()

    # Initialize pending update (empty until an update is available)
    self._pending_update: PendingUpdate = PendingUpdate()

    super().__init__()

accepts_version(version)

Tell whether an update to version would not roll the firmware back.

A newer version than the installed firmware's is accepted, an older one never is, and the same one only when allow_same_version is set. Versions compare by Semantic Versioning precedence, so build metadata is ignored.

Parameters:

Name Type Description Default
version str

Version of the update, in Semantic Versioning 2.0.0

required

Returns:

Type Description
bool

True if the update may proceed, False if it must be ignored.

Raises:

Type Description
ValueError

If version or the installed firmware's version is not a valid Semantic Versioning 2.0.0 string

Source code in tolomeo/plugins/ota/fsm.py
def accepts_version(self, version: str) -> bool:
    """Tell whether an update to ``version`` would not roll the firmware back.

    A newer version than the installed firmware's is accepted, an older one
    never is, and the same one only when `allow_same_version` is set.
    Versions compare by Semantic Versioning precedence, so build metadata
    is ignored.

    Args:
        version: Version of the update, in Semantic Versioning 2.0.0

    Returns:
        True if the update may proceed, False if it must be ignored.

    Raises:
        ValueError: If ``version`` or the installed firmware's version is
            not a valid Semantic Versioning 2.0.0 string

    """
    candidate = SemanticVersion.parse(version)
    installed = SemanticVersion.parse(self._current_firmware.version)
    return candidate > installed or (candidate == installed and self.allow_same_version)

clear_checkpoint()

Remove the persisted checkpoint file, if present.

Source code in tolomeo/plugins/ota/fsm.py
def clear_checkpoint(self) -> None:
    """Remove the persisted checkpoint file, if present."""
    self._checkpoint_file.unlink(missing_ok=True)

clear_pending_update()

Clear the pending update information.

Source code in tolomeo/plugins/ota/fsm.py
def clear_pending_update(self) -> None:
    """Clear the pending update information."""
    self._pending_update = PendingUpdate()

on_enter_bootstrapping()

Handle the entry into the 'Bootstrapping' state.

This method is called when the state machine transitions to the 'Bootstrapping' state. It loads the saved checkpoint (if available), restores the firmware info, and transitions to the last checkpointed state.

A checkpointed update that accepts_version rejects is dropped, and the machine resolves to updated: the firmware may have been updated by other means while the checkpoint sat on disk.

A deferred machine has not yet been told where its checkpoint lives, so this returns without reading anything until checkpoint_file is assigned.

Source code in tolomeo/plugins/ota/fsm.py
def on_enter_bootstrapping(self) -> None:
    """Handle the entry into the 'Bootstrapping' state.

    This method is called when the state machine transitions to the
    'Bootstrapping' state. It loads the saved checkpoint (if available),
    restores the firmware info, and transitions to the last checkpointed state.

    A checkpointed update that `accepts_version` rejects is dropped, and the
    machine resolves to ``updated``: the firmware may have been updated by
    other means while the checkpoint sat on disk.

    A deferred machine has not yet been told where its checkpoint lives, so
    this returns without reading anything until `checkpoint_file` is assigned.

    """
    if not self._checkpoint_resolved:
        self._logger.debug("Checkpoint location not resolved yet; deferring bootstrap")
        return

    if not self._checkpoint_file.exists():
        self._logger.info("No checkpoint found at %s, defaulting to updated", self._checkpoint_file)
        self.detect_up_to_date()
        return

    try:
        self._logger.info("Loading from saved checkpoint: %s", self._checkpoint_file)
        snapshot = OTASnapshot.load(self._checkpoint_file)

        pending = snapshot.pending_update
        if not self.accepts_version(pending.version or ""):
            self._logger.warning(
                "Dropping checkpointed update %s %s: installed firmware is %s",
                pending.name,
                pending.version,
                self._current_firmware.version,
            )
            self.detect_up_to_date()
            return

        # Restore pending update info from checkpoint
        self._pending_update = snapshot.pending_update
        self._logger.info(
            "Restored pending update:  %s %s", self._pending_update.name, self._pending_update.version
        )

        # Transition to the checkpointed state
        self._detect_checkpointed_state(snapshot.state)

    except FileNotFoundError:
        self._logger.error("Checkpoint file not found at %s, defaulting to updated", self._checkpoint_file)
        self.detect_up_to_date()
    except (yaml.YAMLError, KeyError, ValueError) as e:
        self._logger.error("Failed to parse checkpoint file: %s. Defaulting to updated", e)
        self.detect_up_to_date()
    except PermissionError as e:
        self._logger.error("Permission denied reading checkpoint file %s: %s", self._checkpoint_file, e)
        self.detect_up_to_date()
    except Exception:
        self._logger.exception("Unexpected error loading checkpoint from %s", self._checkpoint_file)
        self.detect_up_to_date()

on_enter_state(event, source, target, **kwargs)

Announce every state except the transient trip into bootstrapping.

Runs once per transition, after current_state has been updated to target — unlike on_transition, which the underlying engine calls before that assignment. Keying the guard on the target rather than the source matters at startup: the state a restarted service resolves to is reached from bootstrapping, and suppressing that left consumers with no way to learn it short of asking GetOTAStatus.

Parameters:

Name Type Description Default
event str

The name of the triggering event.

required
source State

The state from which the transition originated.

required
target State

The new state after the transition.

required
**kwargs Any

Additional keyword arguments for transition hooks.

{}
Source code in tolomeo/plugins/ota/fsm.py
def on_enter_state(self, event: str, source: State, target: State, **kwargs: Any) -> None:
    """Announce every state except the transient trip into `bootstrapping`.

    Runs once per transition, after ``current_state`` has been updated to
    ``target`` — unlike `on_transition`, which the underlying engine calls
    *before* that assignment. Keying the guard on the target rather than the
    source matters at startup: the state a restarted service resolves to is
    reached *from* bootstrapping, and suppressing that left consumers with
    no way to learn it short of asking ``GetOTAStatus``.

    Args:
        event: The name of the triggering event.
        source: The state from which the transition originated.
        target: The new state after the transition.
        **kwargs: Additional keyword arguments for transition hooks.

    """
    if target.value != self.bootstrapping.value:
        self._schedule_push()

on_exit_bootstrapping()

Handle exit from bootstrapping state.

Source code in tolomeo/plugins/ota/fsm.py
def on_exit_bootstrapping(self) -> None:
    """Handle exit from bootstrapping state."""

on_transition(event, source, target, **kwargs)

Handle actions after each state transition.

This method is automatically called by the underlying state machine whenever a transition occurs, before current_state is updated to target. It logs the transition and, if the target state is one that can be restored from a checkpoint, saves the current state. Pushing the updated state is handled separately by on_enter_state, which the engine calls after the assignment — at this point current_state still reports the source state, so a push from here would announce the wrong value.

Parameters:

Name Type Description Default
event str

The name of the triggering event (e.g., 'download', 'install')

required
source State

The state from which the transition originated

required
target State

The new state after the transition

required
**kwargs Any

Additional keyword arguments for transition hooks

{}
Source code in tolomeo/plugins/ota/fsm.py
def on_transition(self, event: str, source: State, target: State, **kwargs: Any) -> None:
    """Handle actions after each state transition.

    This method is automatically called by the underlying state machine
    whenever a transition occurs, *before* ``current_state`` is updated to
    ``target``. It logs the transition and, if the target state is one
    that can be restored from a checkpoint, saves the current state.
    Pushing the updated state is handled separately by
    `on_enter_state`, which the engine calls after the assignment — at
    this point `current_state` still reports the *source* state, so a
    push from here would announce the wrong value.

    Args:
        event: The name of the triggering event (e.g., 'download', 'install')
        source: The state from which the transition originated
        target: The new state after the transition
        **kwargs: Additional keyword arguments for transition hooks

    """
    self._logger.info("OTA STATE: Transitioned from %s to %s via '%s'", source.id, target.id, event)

    # States that can be restored from checkpoint
    checkpointable_states: Set = {
        self.update_available.value,
        self.downloading.value,
        self.paused.value,
        self.update_ready.value,
        self.download_failed.value,
        self.update_failed.value,
    }

    # Save checkpoint if transitioning to a checkpointable state
    if target.value in checkpointable_states:
        self._logger.info("Saving checkpoint in state: %s", target.value)
        try:
            snapshot = OTASnapshot(
                state=target.value,
                current_firmware=self._current_firmware,
                pending_update=self._pending_update,
            )
            snapshot.save(self._checkpoint_file)
        except (OSError, IOError) as e:
            self._logger.error("Failed to save checkpoint to %s: %s", self._checkpoint_file, e)
        except Exception as e:
            self._logger.exception("Unexpected error while saving checkpoint: %s", e)

    if target.value in {self.updated.value, self.update_successful.value}:
        self._checkpoint_file.unlink(missing_ok=True)

set_pending_update(name=None, version=None, url=None, download_path=None)

Set or update the pending update information.

Parameters:

Name Type Description Default
name str | None

Name of the firmware update

None
version str | None

Version of the firmware update

None
url str | None

URL from which to download the update

None
download_path str | Path | None

Path where the update will be saved

None
Source code in tolomeo/plugins/ota/fsm.py
def set_pending_update(
    self,
    name: str | None = None,
    version: str | None = None,
    url: str | None = None,
    download_path: str | Path | None = None,
) -> None:
    """Set or update the pending update information.

    Args:
        name: Name of the firmware update
        version: Version of the firmware update
        url: URL from which to download the update
        download_path: Path where the update will be saved

    """
    if name is not None:
        self._pending_update.name = name
    if version is not None:
        self._pending_update.version = version
    if url is not None:
        self._pending_update.url = url
    if download_path is not None:
        self._pending_update.download_path = str(download_path)

Firmware models

tolomeo.plugins.ota.models.InstalledFirmware dataclass

Firmware currently installed on the system.

Read from /usr/lib/os-release at runtime via load. The fields map to FIRMWARE_NAME and BUILD_VERSION in that file.

Attributes:

Name Type Description
name str

The firmware name (e.g. "tolomeo-image-prod").

version str

The firmware build version (e.g. "2.1.0").

Source code in tolomeo/plugins/ota/models.py
@dataclass
class InstalledFirmware:
    """Firmware currently installed on the system.

    Read from ``/usr/lib/os-release`` at runtime via `load`. The fields map
    to ``FIRMWARE_NAME`` and ``BUILD_VERSION`` in that file.

    Attributes:
        name: The firmware name (e.g. ``"tolomeo-image-prod"``).
        version: The firmware build version (e.g. ``"2.1.0"``).

    """

    name: str
    version: str

    def asdict(self) -> Dict[str, str]:
        """Render the firmware info as a plain dict for serialisation."""
        return asdict(self)

    @classmethod
    def load(cls) -> InstalledFirmware:
        """Load current firmware info from the system."""
        return cls(cls._read_os_field("FIRMWARE_NAME"), cls._read_os_field("BUILD_VERSION"))

    @classmethod
    def _read_os_field(cls, key: str) -> str:
        """Read a specific field from /usr/lib/os-release.

        Args:
            key: Field name to retrieve (e.g., 'BUILD_VERSION', 'FIRMWARE_NAME')

        Returns:
            The field value

        Raises:
            FileNotFoundError: If os-release file doesn't exist
            ValueError: If the field is not found or empty
            PermissionError: If the file cannot be read

        """
        try:
            prefix = f"{key}="
            for line in OS_RELEASE_PATH.read_text().splitlines():
                if line.startswith(prefix):
                    value = line.split("=", 1)[1].strip('"')
                    if value:
                        return value

            raise ValueError(f"{key} not found in {OS_RELEASE_PATH}")

        except FileNotFoundError as exc:
            raise FileNotFoundError(f"os-release file not found at {OS_RELEASE_PATH}") from exc
        except PermissionError as exc:
            raise PermissionError(f"Permission denied reading {OS_RELEASE_PATH}") from exc

asdict()

Render the firmware info as a plain dict for serialisation.

Source code in tolomeo/plugins/ota/models.py
def asdict(self) -> Dict[str, str]:
    """Render the firmware info as a plain dict for serialisation."""
    return asdict(self)

load() classmethod

Load current firmware info from the system.

Source code in tolomeo/plugins/ota/models.py
@classmethod
def load(cls) -> InstalledFirmware:
    """Load current firmware info from the system."""
    return cls(cls._read_os_field("FIRMWARE_NAME"), cls._read_os_field("BUILD_VERSION"))

tolomeo.plugins.ota.models.PendingUpdate dataclass

A firmware update that is available to be applied.

Populated by OTAMixin.notify_update and persisted in the OTA checkpoint file across reboots. All fields are optional because the update info can be provided incrementally (e.g. URL given at notify time, destination path confirmed at download time).

Attributes:

Name Type Description
name str | None

Firmware name of the pending update.

version str | None

Firmware version of the pending update.

url str | None

Download URL for the update package.

download_path str | None

Local filesystem path where the artifact is saved. OTAPlugin fills it from OTAOptions.download_dest when not given explicitly.

Source code in tolomeo/plugins/ota/models.py
@dataclass
class PendingUpdate:
    """A firmware update that is available to be applied.

    Populated by `OTAMixin.notify_update` and persisted in the OTA
    checkpoint file across reboots. All fields are optional because
    the update info can be provided incrementally (e.g. URL given at
    notify time, destination path confirmed at download time).

    Attributes:
        name: Firmware name of the pending update.
        version: Firmware version of the pending update.
        url: Download URL for the update package.
        download_path: Local filesystem path where the artifact is saved.
            `OTAPlugin` fills it from `OTAOptions.download_dest` when not given
            explicitly.

    """

    name: str | None = None
    version: str | None = None
    url: str | None = None
    download_path: str | None = None

    def asdict(self) -> Dict[str, str]:
        """Render the pending update as a plain dict for serialisation.

        Returns:
            The set fields only; unset ones are omitted rather than sent as
            nulls, so a partially known update reads as such.

        """
        return {k: v for k, v in asdict(self).items() if v is not None}

asdict()

Render the pending update as a plain dict for serialisation.

Returns:

Type Description
Dict[str, str]

The set fields only; unset ones are omitted rather than sent as

Dict[str, str]

nulls, so a partially known update reads as such.

Source code in tolomeo/plugins/ota/models.py
def asdict(self) -> Dict[str, str]:
    """Render the pending update as a plain dict for serialisation.

    Returns:
        The set fields only; unset ones are omitted rather than sent as
        nulls, so a partially known update reads as such.

    """
    return {k: v for k, v in asdict(self).items() if v is not None}