devkit_ui.missions.store

Mission storage and scheduling for the Sowbot webui.

Owns the mission list, its YAML persistence, and the ACTIONS registry. Designed to be attached to a NiceGuiNode (see MissionStore.attach) so the node exposes a read-only snapshot and a version counter that the UI timer polls to decide when to rebuild. Does not inherit from rclpy.node.Node.

Unlike obstacles, missions are not broadcast on a ROS topic — they exist to drive the executor in ui_node.py, which dispatches sowbot_row_follow goals and toggles tool topics. The store knows nothing about ROS itself.

Threading model

self._missions is an immutable tuple. All writes replace it wholesale under self._lock, and bump self._version in the same critical section. Readers grab the tuple reference lock-free (CPython attribute reads are atomic) and walk a consistent snapshot. Same pattern as obstacles.py.

Persistence is serialised through a single background writer thread (self._write_q). Callers snapshot the current tuple inside the lock and enqueue (snapshot, status_msg). The writer drains the queue, keeping only the most-recent snapshot per wakeup, so rapid back-to-back writes do not pile up threads and the status message is never stale.

Scheduling

Each mission carries one optional integer field, repeat_every_hours:

None → one-shot: runs once when active, auto-deactivates on success. N → recurring: re-arms N hours after the last successful run.

today_queue() is the only scheduler primitive: it returns (mission_id, row_id, action, action_params) tuples for every active mission that is due right now. A mission is due if it has never run, if its last run failed (retry asap — operator-gated, no automatic loop because the executor is operator-triggered), or if its repeat interval has elapsed. There is no cron, no calendar, no time-of-day. If that’s ever asked for, it’s a separate scheduler, not this one.

record_run(mid, success) is the convergence point: both the scheduled executor and any manual “Run now” path call it. That keeps the repeat interval anchored on actual robot activity rather than wall-clock ticks that ignore manual runs.

reset(mid) is the re-arm path: clears last_run_at / last_run_success and re-activates. Use it to re-run a completed one-shot (e.g. crop re-planted) or to immediately retry a failed mission outside the normal scheduler pass.

Action registry

ACTIONS is a module-level dict for now. The tool_topic is the std_msgs/Bool topic the executor publishes True/False on to engage and disengage the implement; None means the action drives only. The dict shape is the documented seam — when a third action with parameters (RPM, depth, blade height) arrives, refactor to a proper class.

Timestamp format

Timestamps are written as ISO 8601 UTC: 2025-04-01T09:30:00Z. Records written by earlier versions of this module (dd-mm-yyyy_hh-mm-ss) are read transparently by _parse_ts and silently migrated to ISO 8601 on the next save.

next_due_in_hours() sentinels

Import DUE_NOW and DUE_FAILED rather than comparing against magic floats:

DUE_NOW (0.0) mission is active and has never run DUE_FAILED (-1.0) mission is active and last run failed > 0.0 hours until the recurring interval elapses None inactive, completed one-shot, or unknown id

Functions

validate_mission(name, rows, action, ...)

Return an error string, or None if OK.

Classes

MissionStore([path])

Owns the mission list and its YAML persistence.

class devkit_ui.missions.store.MissionStore(path: str = '/workspace/maps/missions.db')[source]

Bases: object

Owns the mission list and its YAML persistence.

After attach(node), the node exposes:

node.missions : tuple[dict, …] read-only snapshot node.missions_version : int bumps on every change node.mission_status : str last-action status

Mission record schema:

id: ‘MISSION_1’ (allocated, immutable) name: str (operator label; defaults to id) rows: list[str] (topo entry-node names) action: str (key in ACTIONS) action_params: dict (per-action parameter overrides) repeat_every_hours: int | None (None == one-shot) active: bool created_at: str (ISO 8601 UTC) last_run_at: str | None last_run_success: bool | None

add(*, rows: list, action: str, action_params: dict | None = None, name: str = '', repeat_every_hours: int | None = None, active: bool = True) → str | None[source]

Add a mission. Returns its allocated id, or None on failure. Status carries the reason either way.

attach(node) → None[source]

Wire into a NiceGuiNode. Kicks off a background load.

close() → None[source]

Release backend resources (the SQLite connection). A no-op for the YAML backend.

delete(mid: str) → bool[source]
find(mid: str) → dict | None[source]

Lock-free single-mission lookup by id. Returns the dict from the current snapshot — treat as read-only.

find_by_name(name: str) → dict | None[source]

Lock-free lookup by operator name. Returns the first match. Useful for collision checks before add() (e.g. the UI_RUN record the executor creates to anchor repeat intervals).

next_due_in_hours(mid: str) → float | None[source]

For the UI chip. Returns one of:

DUE_NOW (0.0) active, never run yet DUE_FAILED (-1.0) active, last run failed — retry sentinel > 0.0 hours until the recurring interval elapses None inactive, completed one-shot, or unknown id

Import DUE_NOW / DUE_FAILED from this module rather than comparing against magic floats. UI rendering pattern:

h = store.next_due_in_hours(mid) if h is None: label = ‘done’ elif h == DUE_FAILED: label = ‘retry’ elif h == DUE_NOW: label = ‘due now’ else: label = f’in {h:.1f}h’

record_run(mid: str, success: bool) → bool[source]

Record a run outcome. Called by the executor (and any ‘Run now’ path) so the repeat interval re-arms from actual robot activity regardless of who triggered the run.

Side effect: one-shot missions (repeat_every_hours is None) that complete successfully are auto-deactivated.

Returns False for an unknown ID; otherwise returns whether the backend accepted the update.

reset(mid: str) → bool[source]

Re-arm a mission: clear run history and re-activate.

The canonical path for:
  • a completed one-shot that needs to run again (e.g. crop re-planted),

  • a failed mission the operator wants to retry without waiting for the next scheduled executor pass.

Does not modify other mission fields. Returns False for an unknown ID; otherwise returns whether the backend accepted the update.

set_active(mid: str, active: bool) → bool[source]

Convenience wrapper around update(). The common toggle.

today_queue() → list[tuple[str, str, str, dict]][source]

Active+due missions expanded into (mission_id, row_id, action, action_params) tuples in declaration order. Lock-free; the executor walks this list and dispatches each tuple in sequence.

update(mid: str, **fields) → bool[source]
devkit_ui.missions.store.validate_mission(name: str, rows: list, action: str, repeat_every_hours: int | None) → str | None[source]

Return an error string, or None if OK.

Expects name to already be cleaned (uppercase, only [A-Z0-9_]). MissionStore.add() and .update() clean before calling; external callers should do the same or pass name=’’ to use the id default.