diff --git a/doc/embedded/app_features/task_runner.rst b/doc/embedded/app_features/task_runner.rst index 3f08ca6dc..e5f447431 100644 --- a/doc/embedded/app_features/task_runner.rst +++ b/doc/embedded/app_features/task_runner.rst @@ -57,6 +57,207 @@ scheduling conditions in a compact form, for example: }, }; +Common Scheduling Fields +************************ + +Each :c:struct:`task_schedule` contains the following common fields, independent +of the task-specific arguments described in the next section: + +``task_id`` + Identifies the task implementation that this schedule starts. Multiple + schedules can reference the same task ID, although only one schedule for a + task implementation can run at a time. + +``validity`` + Controls when the schedule itself is valid. :c:enumerator:`TASK_VALID_ALWAYS` + is always eligible, :c:enumerator:`TASK_VALID_ACTIVE` is eligible only while + :c:enumerator:`INFUSE_STATE_APPLICATION_ACTIVE` is set, and + :c:enumerator:`TASK_VALID_INACTIVE` is eligible only while it is clear. + :c:enumerator:`TASK_VALID_PERMANENTLY_RUNS` bypasses normal entry and exit + checks and restarts the task if it terminates. The + :c:enumerator:`TASK_LOCKED` flag can be ORed into this field to prevent KV + store updates from replacing the schedule. + +``periodicity_type`` + Selects which member of the ``periodicity`` union is used for start timing. + A zero value means there is no periodicity condition, so start timing is + controlled only by the other start conditions. + +``boot_lockout_minutes`` + Prevents the task from starting until the application has been running for + this many minutes. A value of ``0`` disables the boot lockout. + +``timeout_s`` + Requests task termination once the current run has lasted this many seconds. + A value of ``0`` disables timeout-based termination. + +``battery_start`` + Optional battery charge thresholds for starting the task. ``lower`` requires + the battery percentage to be greater than or equal to the configured value, + while ``upper`` requires it to be less than or equal to the configured value. + A threshold value of ``0`` disables that side of the range. + + Example: + + .. code-block:: c + + .battery_start.lower = 30, + .battery_start.upper = 80, + + This schedule can only start when the battery charge is between 30% and 80%, + inclusive. If only ``lower`` was set, the task could start at 30% or above; if + only ``upper`` was set, it could start at 80% or below. + +``battery_terminate`` + Optional battery charge thresholds for terminating the task. ``lower`` + requests termination when the battery percentage is less than or equal to the + configured value, while ``upper`` requests termination when it is greater than + or equal to the configured value. A threshold value of ``0`` disables that + threshold. + + Example: + + .. code-block:: c + + .battery_terminate.lower = 20, + + Once the task is running, this requests termination if the battery charge + falls to 20% or below. + +``periodicity.fixed.period_s`` + Used with :c:enumerator:`TASK_PERIODICITY_FIXED`. The task can start only + when the current global time is on an ``N`` second boundary. + +``periodicity.lockout.lockout_s`` + Used with :c:enumerator:`TASK_PERIODICITY_LOCKOUT`. The task can start only + after this many seconds have elapsed since the schedule last started. OR in + :c:macro:`TASK_RUNNER_LOCKOUT_IGNORE_FIRST` to allow the first run to start + without waiting for the initial lockout period. + + Example: + + .. code-block:: c + + .periodicity_type = TASK_PERIODICITY_LOCKOUT, + .periodicity.lockout.lockout_s = + TASK_RUNNER_LOCKOUT_IGNORE_FIRST | (30 * SEC_PER_MIN), + + The first run may start as soon as the other start conditions pass. After + that, each run is separated from the previous start time by at least 30 + minutes. + +``periodicity.after.schedule_idx`` and ``periodicity.after.duration_s`` + Used with :c:enumerator:`TASK_PERIODICITY_AFTER`. The task can start + ``duration_s`` seconds after the schedule at ``schedule_idx`` terminates. + + Example: + + .. code-block:: c + + .periodicity_type = TASK_PERIODICITY_AFTER, + .periodicity.after.schedule_idx = 0, + .periodicity.after.duration_s = 10, + + This schedule can start 10 seconds after schedule index 0 terminates, assuming + the other start conditions are also satisfied. + +``periodicity.lockout_dynamic_battery`` + Used with :c:enumerator:`TASK_PERIODICITY_LOCKOUT_DYNAMIC_BATTERY`. The + lockout behaves like :c:enumerator:`TASK_PERIODICITY_LOCKOUT`, but the + interval is derived from the current battery percentage. The lockout is + ``lockout_min`` at or below ``battery_min``, ``lockout_max`` at or above + ``battery_max``, and linearly interpolated between those points. + + Example: + + .. code-block:: c + + .periodicity_type = TASK_PERIODICITY_LOCKOUT_DYNAMIC_BATTERY, + .periodicity.lockout_dynamic_battery = + { + .battery_min = 20, + .battery_max = 80, + .lockout_min = 60 * SEC_PER_MIN, + .lockout_max = 10 * SEC_PER_MIN, + }, + + At 20% battery or below, runs are separated by 60 minutes. At 80% battery or + above, runs are separated by 10 minutes. Between those thresholds, the lockout + is linearly interpolated, so a mid-range battery gives a mid-range lockout. + +``states_start_timeout_2x_s`` + Optional fallback for the start state conditions. When non-zero, + ``states_start`` is treated as satisfied once twice this value in seconds has + elapsed since the schedule last started. Use + :c:macro:`TASK_STATES_START_TIMEOUT` when initialising this field. + + Example: + + .. code-block:: c + + .states_start_timeout_2x_s = TASK_STATES_START_TIMEOUT(20 * SEC_PER_MIN), + .states_start = TASK_STATES_DEFINE(INFUSE_STATE_TIME_KNOWN), + + The task can start when time is known. If that state is not set, the state + condition is still treated as satisfied once 20 minutes have elapsed since the + schedule last started. + +``states_start`` + Application state conditions that must evaluate true before the task can + start. Construct this field with :c:macro:`TASK_STATES_DEFINE`; conditions are + ANDed by default, can be inverted with :c:macro:`TR_NOT`, and can be ORed with + :c:macro:`TR_OR`. + + Example: + + .. code-block:: c + + .states_start = TASK_STATES_DEFINE( + TR_NOT | INFUSE_STATE_DEVICE_STATIONARY, + INFUSE_STATE_TIME_KNOWN), + + The task can start only when the device is not stationary and the global time + is known. + + .. code-block:: c + + .states_start = TASK_STATES_DEFINE( + INFUSE_STATE_DEVICE_STARTED_MOVING, + TR_OR | INFUSE_STATE_HIGH_PRIORITY_UPLINK), + + The task can start when either the device has started moving or a high + priority uplink is requested. + +``states_terminate`` + Application state conditions that request task termination when they evaluate + true. This field uses the same :c:macro:`TASK_STATES_DEFINE`, + :c:macro:`TR_NOT`, and :c:macro:`TR_OR` helpers as ``states_start``. + + Example: + + .. code-block:: c + + .states_terminate = TASK_STATES_DEFINE(INFUSE_STATE_DEVICE_STATIONARY), + + Once the task is running, this requests termination when the device becomes + stationary. + +``task_logging`` + Common logging configuration for task output. Each entry selects a set of TDF + loggers and a task-defined TDF mask. The task implementation decides which + masks are meaningful. + + Example: + + .. code-block:: c + + .task_logging[0].loggers = TDF_DATA_LOGGER_SERIAL, + .task_logging[0].tdf_mask = TASK_GNSS_LOG_LLHA | TASK_GNSS_LOG_FIX_INFO, + + The task may emit the LLHA and fix information TDFs to the serial logger. The + ``tdf_mask`` bits are task-specific, so the available values depend on the + selected ``task_id``. + Task Arguments ************** @@ -151,6 +352,30 @@ flag to the :c:member:`task_schedule.validity` field of the schedule like below: This flag will prevent :c:func:`task_runner_schedules_load` from modifying the provided schedule, regardless of the value saved in the KV store. +Inspecting Encoded Schedules +**************************** + +Encoded task schedules can be inspected with ``infuse schedule decode``. Pass +the schedule payload as hex or base64 to print a readable description: + +.. code-block:: console + + infuse schedule decode + +Use ``--python`` to emit Python assignment lines instead. This is useful when +turning an existing encoded schedule into a starting point for small edits: + +.. code-block:: console + + infuse schedule decode --python + +The ``python-tools/scripts/encode_task_schedule_example.py`` script shows the +opposite flow: build an ``infuse_iot.task_runner.schedule.TaskSchedule`` in +Python, set common fields, task logging, and task-specific arguments, then print +the encoded bytes as hex or base64. Copy the decoded assignments into a similar +script, adjust the fields of interest, and re-encode the schedule for a KV +update or other deployment path. + Task Schedule vs Task Implementation ************************************ diff --git a/scripts/west_commands/cloudgen.py b/scripts/west_commands/cloudgen.py index 5fbc24d9d..2aeb6f0ad 100644 --- a/scripts/west_commands/cloudgen.py +++ b/scripts/west_commands/cloudgen.py @@ -65,7 +65,6 @@ def do_run(self, args, unknown_args): trim_blocks=True, lstrip_blocks=True, ) - if self.extra_defs_base and not self.extra_defs_base.exists(): sys.exit(f"Path '{self.extra_defs_base}' does not exist") @@ -146,9 +145,71 @@ def _task_value_expr(self, value): return f"BIT({value['bit']})" return value["value"] + def _task_py_value_expr(self, value): + if "bit" in value: + return f"BIT({value['bit']})" + if value["value"] == 0 and value["name"].endswith("_MODE"): + return "0x00" + return value["value"] + + def _snake_to_pascal(self, name: str): + return "".join(part.capitalize() for part in name.lower().split("_")) + + def _task_py_class(self, task_name: str): + return f"Task{self._snake_to_pascal(task_name)}" + + def _task_py_member_class(self, type_name: str, task_name: str): + task_base = task_name.lower() + for prefix in [ + f"schedule_struct_task_{task_base}_", + f"schedule_union_task_{task_base}_", + ]: + if type_name.startswith(prefix): + type_name = type_name.removeprefix(prefix) + break + if type_name.endswith("_args"): + type_name = type_name.removesuffix("_args") + return f"{self._snake_to_pascal(type_name)}Args" + + def _task_py_enum_class(self, enum_name: str, task_name: str, field_name: str | None = None): + if field_name == "flags": + return "Flags" + if field_name == "tdfs": + return "Tdfs" + local_name = self._task_enum_local_name(task_name, enum_name) + suffix = local_name.removeprefix(f"task_{task_name.lower()}_") + if suffix == "logs": + return "Logging" + if suffix == "flags": + return "Flags" + if suffix.endswith("_flags"): + suffix = suffix.removesuffix("_flags") + return self._snake_to_pascal(suffix) + + def _task_py_type(self, field, task_name: str, task_defs): + py_type_field = copy.copy(field) + py_type_field.pop("num", None) + struct_name = self._task_type_name(field["type"], "struct") + if struct_name: + base = f"{self._task_py_class(task_name)}.{self._task_py_member_class(struct_name, task_name)}" + else: + union_name = self._task_type_name(field["type"], "union") + if union_name: + base = f"{self._task_py_class(task_name)}.{self._task_py_member_class(union_name, task_name)}" + else: + enum_name = self._task_type_name(field["type"], "enum") + if enum_name: + py_type_field["type"] = task_defs["enums"][enum_name]["type"] + base = self._py_type(py_type_field, False) + + if "num" in field: + return f"{field['num']} * {base}" + return base + def _task_prepare_enum(self, enum_info): for value in enum_info["values"]: value["value_expr"] = self._task_value_expr(value) + value["py_value_expr"] = self._task_py_value_expr(value) def _task_log_enum_name(self, task_name: str, task_defs): task_base = task_name.lower() @@ -217,11 +278,16 @@ def tasksgen(self): task_ids_template = self.env.get_template("task_ids.h.jinja") infuse_task_args_template = self.env.get_template("infuse_task_args.h.jinja") infuse_tasks_template = self.env.get_template("infuse_tasks.h.jinja") + task_definitions_template = self.env.get_template("task_definitions.py.jinja") task_args_output_base = self.generate_base / "include" / "infuse" / "task_runner" / "tasks" task_args_output_base.mkdir(parents=True, exist_ok=True) task_ids_output = task_args_output_base / "infuse_task_ids.h" infuse_task_args_output = task_args_output_base / "infuse_task_args.h" infuse_tasks_output = task_args_output_base / "infuse_tasks.h" + loader = importlib.util.find_spec("infuse_iot") + if loader is None or loader.submodule_search_locations is None: + sys.exit("Unable to locate infuse_iot package") + task_definitions_output = pathlib.Path(next(iter(loader.submodule_search_locations))) / "generated" / "tasks.py" with task_def_file.open("r") as f: task_defs = json.load(f) @@ -298,7 +364,8 @@ def collect_type(ctype, task_name, task_structs, task_unions, task_enums): self._task_prepare_enum(enum_info) task_enums[enum_name] = enum_info - for task in task_defs["definitions"].values(): + python_tasks = [] + for task_id, task in task_defs["definitions"].items(): task = copy.deepcopy(task) task_structs = {} task_unions = {} @@ -324,6 +391,58 @@ def collect_type(ctype, task_name, task_structs, task_unions, task_enums): ) self.clang_format(output) + task["id"] = task_id + task["class_name"] = self._task_py_class(task["name"]) + task["anonymous_fields"] = [field["name"] for field in task["fields"] if field["type"].startswith("union ")] + task["py_enums"] = [] + seen_py_enum_classes = set() + task["logging_class_name"] = None + log_enum_name = self._task_log_enum_name(task["name"], task_defs) + if log_enum_name: + enum_info = copy.deepcopy(task_defs["enums"][log_enum_name]) + enum_info["class_name"] = "Tdfs" if task["name"] == "TDF_LOGGER" else "Logging" + self._task_prepare_enum(enum_info) + task["py_enums"].append(enum_info) + seen_py_enum_classes.add(enum_info["class_name"]) + task["logging_class_name"] = enum_info["class_name"] + for field in task["fields"]: + field["py_type"] = self._task_py_type(field, task["name"], task_defs) + enum_name = self._task_type_name(field["type"], "enum") + if enum_name: + enum_info = copy.deepcopy(task_defs["enums"][enum_name]) + enum_info["class_name"] = self._task_py_enum_class(enum_name, task["name"], field["name"]) + if enum_info["class_name"] not in seen_py_enum_classes: + self._task_prepare_enum(enum_info) + task["py_enums"].append(enum_info) + seen_py_enum_classes.add(enum_info["class_name"]) + for idx, (alt_id, _alt) in enumerate(task.get("alternate_ids", {}).items(), 1): + task[f"alt{idx}_id"] = int(alt_id) + + task["structs"] = task_structs + for name, info in task["structs"].items(): + info["class_name"] = self._task_py_member_class(name, task["name"]) + info["py_enums"] = [] + for field in info["fields"]: + field["py_type"] = self._task_py_type(field, task["name"], task_defs) + enum_name = self._task_type_name(field["type"], "enum") + if enum_name: + enum_info = copy.deepcopy(task_defs["enums"][enum_name]) + enum_info["class_name"] = self._task_py_enum_class(enum_name, task["name"], field["name"]) + self._task_prepare_enum(enum_info) + info["py_enums"].append(enum_info) + + task["unions"] = task_unions + for name, info in task["unions"].items(): + info["class_name"] = self._task_py_member_class(name, task["name"]) + for field in info["fields"]: + field["py_type"] = self._task_py_type(field, task["name"], task_defs) + + python_tasks.append(task) + + with task_definitions_output.open("w", encoding="utf-8") as f: + f.write(task_definitions_template.render(tasks=python_tasks)) + self.ruff_format(task_definitions_output) + def tdfgen(self): tdf_def_file = self.definition_dir / "tdf.json" tdf_template = self.env.get_template("tdf_definitions.h.jinja") diff --git a/scripts/west_commands/templates/task_definitions.py.jinja b/scripts/west_commands/templates/task_definitions.py.jinja new file mode 100644 index 000000000..5e56da4fb --- /dev/null +++ b/scripts/west_commands/templates/task_definitions.py.jinja @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# mypy: ignore-errors +"""Autogenerated ctypes mirrors of Infuse task-specific argument structs.""" + +import ctypes + + +def BIT(n: int) -> int: + return 1 << n + + +class TdfDataLogger: + FLASH_ONBOARD = BIT(0) + FLASH_REMOVABLE = BIT(1) + SERIAL = BIT(2) + UDP = BIT(3) + BT_ADV = BIT(4) + BT_PERIPH = BIT(5) + + +{% for task in tasks %} +class {{ task['class_name'] }}: + ID = {{ task['id'] }} +{% for alt_id, _alt in task.get('alternate_ids', {}).items() %} + ALT{{ loop.index }}_ID = {{ alt_id }} +{% endfor %} +{% if task['py_enums'] %} + +{% for enum in task['py_enums'] %} + class {{ enum['class_name'] }}: +{% for value in enum['values'] %} + {{ value['name'] }} = {{ value['py_value_expr'] }} +{% if value.get('description') %} + """{{ value['description'] }}""" +{% endif %} +{% endfor %} + +{% endfor %} +{% endif %} +{% for _name, info in task['structs'].items() %} + class {{ info['class_name'] }}(ctypes.LittleEndianStructure): +{% if info['py_enums'] %} +{% for enum in info['py_enums'] %} + class {{ enum['class_name'] }}: +{% for value in enum['values'] %} + {{ value['name'] }} = {{ value['py_value_expr'] }} +{% if value.get('description') %} + """{{ value['description'] }}""" +{% endif %} +{% endfor %} + +{% endfor %} +{% endif %} + _pack_ = 1 + +{% endfor %} +{% for _name, info in task['unions'].items() %} + class {{ info['class_name'] }}(ctypes.Union): + _pack_ = 1 + +{% endfor %} + class Args(ctypes.LittleEndianStructure): + _pack_ = 1 +{% if task['anonymous_fields'] %} + _anonymous_ = ( +{% for field_name in task['anonymous_fields'] %} + "{{ field_name }}", +{% endfor %} + ) +{% endif %} + + +{% for _name, info in task['structs'].items() %} +{{ task['class_name'] }}.{{ info['class_name'] }}._fields_ = [ +{% for field in info['fields'] %} + ("{{ field['name'] }}", {{ field['py_type'] }}), +{% endfor %} +] +{% endfor %} +{% for _name, info in task['unions'].items() %} +{{ task['class_name'] }}.{{ info['class_name'] }}._fields_ = [ +{% for field in info['fields'] %} + ("{{ field['name'] }}", {{ field['py_type'] }}), +{% endfor %} +] +{% endfor %} +{{ task['class_name'] }}.Args._fields_ = [ +{% for field in task['fields'] %} + ("{{ field['name'] }}", {{ field['py_type'] }}), +{% endfor %} +] + + +{% endfor %} +class TaskArguments(ctypes.Union): + TASK_IDS = { +{% for task in tasks %} + "{{ task['name'] | lower }}": {{ task['class_name'] }}.ID, +{% for alt_id, alt in task.get('alternate_ids', {}).items() %} + "{{ alt['name'] | lower }}": {{ task['class_name'] }}.ALT{{ loop.index }}_ID, +{% endfor %} +{% endfor %} + } + + TASK_ARG_FIELDS = { +{% for task in tasks %} + {{ task['class_name'] }}.ID: "{{ task['name'] | lower }}", +{% for alt_id, _alt in task.get('alternate_ids', {}).items() %} + {{ task['class_name'] }}.ALT{{ loop.index }}_ID: "{{ task['name'] | lower }}", +{% endfor %} +{% endfor %} + } + + TASK_LOGGING_CLASSES = { +{% for task in tasks %} +{% if task['logging_class_name'] %} + {{ task['class_name'] }}.ID: {{ task['class_name'] }}.{{ task['logging_class_name'] }}, +{% for alt_id, _alt in task.get('alternate_ids', {}).items() %} + {{ task['class_name'] }}.ALT{{ loop.index }}_ID: {{ task['class_name'] }}.{{ task['logging_class_name'] }}, +{% endfor %} +{% endif %} +{% endfor %} + } + + _pack_ = 1 + _fields_ = [ + ("raw", ctypes.c_uint8 * 17), +{% for task in tasks %} + ("{{ task['name'] | lower }}", {{ task['class_name'] }}.Args), +{% endfor %} + ] + + +if __name__ == "__main__": + print("Task IDs:") + for name, task_id in TaskArguments.TASK_IDS.items(): + print(f" {name}: {task_id}") + print("Task argument sizes:") + for name, field_type in TaskArguments._fields_: + print(f" {name}: {ctypes.sizeof(field_type)}") + print(f"TaskArguments size: {ctypes.sizeof(TaskArguments)}")