From 09921ae310420098348faafa834a782b6542dea7 Mon Sep 17 00:00:00 2001 From: Jordan Yates Date: Wed, 5 Aug 2026 15:51:05 +1000 Subject: [PATCH 1/3] task_runner: schedule information Add a description of the C schedule structure and a module that can describe a schedule in prose. Assisted-by: GPT-5.5 Signed-off-by: Jordan Yates --- src/infuse_iot/generated/tasks.py | 418 +++++++++++++++++++++++++ src/infuse_iot/task_runner/__init__.py | 25 ++ src/infuse_iot/task_runner/codec.py | 384 +++++++++++++++++++++++ src/infuse_iot/task_runner/describe.py | 376 ++++++++++++++++++++++ src/infuse_iot/task_runner/schedule.py | 135 ++++++++ 5 files changed, 1338 insertions(+) create mode 100644 src/infuse_iot/generated/tasks.py create mode 100644 src/infuse_iot/task_runner/__init__.py create mode 100644 src/infuse_iot/task_runner/codec.py create mode 100644 src/infuse_iot/task_runner/describe.py create mode 100644 src/infuse_iot/task_runner/schedule.py diff --git a/src/infuse_iot/generated/tasks.py b/src/infuse_iot/generated/tasks.py new file mode 100644 index 0000000..531f030 --- /dev/null +++ b/src/infuse_iot/generated/tasks.py @@ -0,0 +1,418 @@ +#!/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) + + +class TaskTdfLogger: + ID = 0 + ALT1_ID = 7 + ALT2_ID = 8 + + class Tdfs: + ANNOUNCE = BIT(0) + """Log announcement TDFs""" + BATTERY = BIT(1) + """Log battery TDFs""" + AMBIENT_ENV = BIT(2) + """Log ambient environmental TDFs""" + LOCATION = BIT(3) + """Log location TDFs""" + ACCEL = BIT(4) + """Log accelerometer TDFs""" + NET_CONN = BIT(5) + """Log network connection TDFs""" + CUSTOM = BIT(6) + """Log custom TDFs""" + SOC_TEMPERATURE = BIT(7) + """Log SoC temperature TDFs""" + + class Flags: + NO_FLUSH = BIT(0) + """Do not flush logger after logging""" + + class Args(ctypes.LittleEndianStructure): + _pack_ = 1 + + +TaskTdfLogger.Args._fields_ = [ + ("loggers", ctypes.c_uint8), + ("logging_period_ms", ctypes.c_uint16), + ("random_delay_ms", ctypes.c_uint16), + ("tdfs", ctypes.c_uint16), + ("flags", ctypes.c_uint8), + ("per_run", ctypes.c_uint8), +] + + +class TaskImu: + ID = 1 + + class Logging: + ACC = BIT(0) + """Log accelerometer samples""" + GYR = BIT(1) + """Log gyroscope samples""" + MAG = BIT(2) + """Log magnetometer samples""" + + class Flags: + LOW_POWER_MODE = BIT(0) + """Run IMU in low power mode""" + + class AccelerometerArgs(ctypes.LittleEndianStructure): + _pack_ = 1 + + class GyroscopeArgs(ctypes.LittleEndianStructure): + _pack_ = 1 + + class MagnetometerArgs(ctypes.LittleEndianStructure): + _pack_ = 1 + + class Args(ctypes.LittleEndianStructure): + _pack_ = 1 + + +TaskImu.AccelerometerArgs._fields_ = [ + ("rate_hz", ctypes.c_uint16), + ("range_g", ctypes.c_uint8), + ("pad", ctypes.c_uint8), +] +TaskImu.GyroscopeArgs._fields_ = [ + ("rate_hz", ctypes.c_uint16), + ("range_dps", ctypes.c_uint16), + ("pad", ctypes.c_uint8), +] +TaskImu.MagnetometerArgs._fields_ = [ + ("rate_hz", ctypes.c_uint16), + ("range_gauss", ctypes.c_uint8), + ("pad", ctypes.c_uint8), +] +TaskImu.Args._fields_ = [ + ("accelerometer", TaskImu.AccelerometerArgs), + ("gyroscope", TaskImu.GyroscopeArgs), + ("magnetometer", TaskImu.MagnetometerArgs), + ("fifo_sample_buffer", ctypes.c_uint16), + ("num_buffers", ctypes.c_uint8), + ("flags", ctypes.c_uint8), +] + + +class TaskBattery: + ID = 2 + + class Logging: + COMPLETE = BIT(0) + """Log battery voltage, charge current and charge percentage""" + VOLTAGE = BIT(1) + """Log battery voltage""" + SOC = BIT(2) + """Log battery charge percentage""" + + class Args(ctypes.LittleEndianStructure): + _pack_ = 1 + + +TaskBattery.Args._fields_ = [ + ("repeat_interval_ms", ctypes.c_uint16), +] + + +class TaskEnvironmental: + ID = 3 + + class Logging: + TPH = BIT(0) + """Log temperature, pressure and humidity""" + T = BIT(1) + """Log temperature""" + P = BIT(2) + """Log pressure""" + + class Args(ctypes.LittleEndianStructure): + _pack_ = 1 + + +TaskEnvironmental.Args._fields_ = [] + + +class TaskGnss: + ID = 4 + + class Logging: + LLHA = BIT(0) + """Log latitude, longitude, height and accuracy""" + FIX_INFO = BIT(1) + """Log fix information""" + PVT = BIT(7) + """Log maximum information position, velocity and time""" + + class Constellations: + GPS = BIT(0) + """GPS constellation""" + GLONASS = BIT(1) + """GLONASS constellation""" + GALILEO = BIT(2) + """Galileo constellation""" + BEIDOU = BIT(3) + """BeiDou constellation""" + QZSS = BIT(4) + """QZSS constellation""" + IRNSS = BIT(5) + """IRNSS constellation""" + SBAS = BIT(6) + """SBAS constellation""" + IMES = BIT(7) + """IMES constellation""" + + class Flags: + RUN_FOREVER = 0 + """Runs until terminated by the scheduler""" + RUN_TO_LOCATION_FIX = 1 + """Terminates when the location is known to specified accuracy""" + RUN_TO_TIME_SYNC = 2 + """Terminates when the time has been synced""" + RUN_MASK = 3 + """Run-until mask""" + LOW_POWER_MODE = 0x00 + """Run GNSS in low power mode""" + PERFORMANCE_MODE = BIT(7) + """Run GNSS in performance mode""" + + class PlateauArgs(ctypes.LittleEndianStructure): + _pack_ = 1 + + class RunToFixArgs(ctypes.LittleEndianStructure): + _pack_ = 1 + + class LowPowerArgs(ctypes.LittleEndianStructure): + _pack_ = 1 + + class ModeArgs(ctypes.Union): + _pack_ = 1 + + class Args(ctypes.LittleEndianStructure): + _pack_ = 1 + _anonymous_ = ("mode",) + + +TaskGnss.PlateauArgs._fields_ = [ + ("min_accuracy_m", ctypes.c_uint8), + ("min_accuracy_improvement_m", ctypes.c_uint8), + ("timeout", ctypes.c_uint8), +] +TaskGnss.RunToFixArgs._fields_ = [ + ("any_fix_timeout", ctypes.c_uint8), + ("fix_plateau", TaskGnss.PlateauArgs), +] +TaskGnss.LowPowerArgs._fields_ = [ + ("acquisition_timeout", ctypes.c_uint16), + ("search_period", ctypes.c_uint16), +] +TaskGnss.ModeArgs._fields_ = [ + ("run_to_fix", TaskGnss.RunToFixArgs), + ("low_power", TaskGnss.LowPowerArgs), +] +TaskGnss.Args._fields_ = [ + ("constellations", ctypes.c_uint8), + ("flags", ctypes.c_uint8), + ("accuracy_m", ctypes.c_uint16), + ("position_dop", ctypes.c_uint16), + ("mode", TaskGnss.ModeArgs), + ("dynamic_model", ctypes.c_uint8), +] + + +class TaskNetworkScan: + ID = 5 + + class Logging: + LTE_CELLS = BIT(0) + """Log LTE TAC cells""" + WIFI_AP = BIT(1) + """Log Wi-Fi access point information""" + COUNT = BIT(2) + """Log network scan count""" + + class Flags: + LTE_CELLS = BIT(0) + """Scan nearby LTE cells""" + WIFI_CELLS = BIT(1) + """Scan nearby Wi-Fi access points""" + SKIP_LTE_IF_WIFI_GOOD = BIT(7) + """Skip LTE scan if desired_aps Wi-Fi access points are found""" + + class WifiArgs(ctypes.LittleEndianStructure): + class Flags: + INCLUDE_DUPLICATES = BIT(0) + """Report multiple networks from the same access point""" + SCAN_PROGRESSIVE = BIT(1) + """Scan Wi-Fi channels over multiple calls in order of most to least common""" + SCAN_ACTIVE = BIT(2) + """Use active scanning""" + INCLUDE_LOCALLY_ADMINISTERED = BIT(3) + """Report networks using locally administered BSSIDs""" + + _pack_ = 1 + + class LteArgs(ctypes.LittleEndianStructure): + _pack_ = 1 + + class Args(ctypes.LittleEndianStructure): + _pack_ = 1 + + +TaskNetworkScan.WifiArgs._fields_ = [ + ("flags", ctypes.c_uint8), + ("desired_aps", ctypes.c_uint8), + ("max_aps", ctypes.c_uint8), +] +TaskNetworkScan.LteArgs._fields_ = [ + ("desired_cells", ctypes.c_uint8), +] +TaskNetworkScan.Args._fields_ = [ + ("flags", ctypes.c_uint8), + ("wifi", TaskNetworkScan.WifiArgs), + ("lte", TaskNetworkScan.LteArgs), +] + + +class TaskBtScanner: + ID = 6 + + class Logging: + INFUSE_BT = BIT(0) + """Log Infuse-IoT Bluetooth packets""" + + class Flags: + LOG_ENCRYPTED = BIT(0) + """Log packets that failed to decrypt""" + FILTER_DUPLICATES = BIT(1) + """Only log each device once""" + DEFER_LOGGING = BIT(2) + """Log observed devices at task termination""" + + class Args(ctypes.LittleEndianStructure): + _pack_ = 1 + + +TaskBtScanner.Args._fields_ = [ + ("duration_ms", ctypes.c_uint32), + ("max_logs", ctypes.c_uint8), + ("flags", ctypes.c_uint8), +] + + +class TaskSocTemperature: + ID = 9 + + class Logging: + T = BIT(0) + """Log SoC temperature""" + + class Args(ctypes.LittleEndianStructure): + _pack_ = 1 + + +TaskSocTemperature.Args._fields_ = [] + + +class TaskAudioDmic: + ID = 10 + + class Logging: + METADATA = BIT(0) + """Log audio metadata""" + SAMPLES = BIT(1) + """Log raw audio samples""" + + class Args(ctypes.LittleEndianStructure): + _pack_ = 1 + + +TaskAudioDmic.Args._fields_ = [ + ("sample_rate_hz", ctypes.c_uint32), + ("gain_db", ctypes.c_int8), +] + + +class TaskArguments(ctypes.Union): + TASK_IDS = { + "tdf_logger": TaskTdfLogger.ID, + "tdf_logger_alt1": TaskTdfLogger.ALT1_ID, + "tdf_logger_alt2": TaskTdfLogger.ALT2_ID, + "imu": TaskImu.ID, + "battery": TaskBattery.ID, + "environmental": TaskEnvironmental.ID, + "gnss": TaskGnss.ID, + "network_scan": TaskNetworkScan.ID, + "bt_scanner": TaskBtScanner.ID, + "soc_temperature": TaskSocTemperature.ID, + "audio_dmic": TaskAudioDmic.ID, + } + + TASK_ARG_FIELDS = { + TaskTdfLogger.ID: "tdf_logger", + TaskTdfLogger.ALT1_ID: "tdf_logger", + TaskTdfLogger.ALT2_ID: "tdf_logger", + TaskImu.ID: "imu", + TaskBattery.ID: "battery", + TaskEnvironmental.ID: "environmental", + TaskGnss.ID: "gnss", + TaskNetworkScan.ID: "network_scan", + TaskBtScanner.ID: "bt_scanner", + TaskSocTemperature.ID: "soc_temperature", + TaskAudioDmic.ID: "audio_dmic", + } + + TASK_LOGGING_CLASSES = { + TaskTdfLogger.ID: TaskTdfLogger.Tdfs, + TaskTdfLogger.ALT1_ID: TaskTdfLogger.Tdfs, + TaskTdfLogger.ALT2_ID: TaskTdfLogger.Tdfs, + TaskImu.ID: TaskImu.Logging, + TaskBattery.ID: TaskBattery.Logging, + TaskEnvironmental.ID: TaskEnvironmental.Logging, + TaskGnss.ID: TaskGnss.Logging, + TaskNetworkScan.ID: TaskNetworkScan.Logging, + TaskBtScanner.ID: TaskBtScanner.Logging, + TaskSocTemperature.ID: TaskSocTemperature.Logging, + TaskAudioDmic.ID: TaskAudioDmic.Logging, + } + + _pack_ = 1 + _fields_ = [ + ("raw", ctypes.c_uint8 * 17), + ("tdf_logger", TaskTdfLogger.Args), + ("imu", TaskImu.Args), + ("battery", TaskBattery.Args), + ("environmental", TaskEnvironmental.Args), + ("gnss", TaskGnss.Args), + ("network_scan", TaskNetworkScan.Args), + ("bt_scanner", TaskBtScanner.Args), + ("soc_temperature", TaskSocTemperature.Args), + ("audio_dmic", TaskAudioDmic.Args), + ] + + +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)}") diff --git a/src/infuse_iot/task_runner/__init__.py b/src/infuse_iot/task_runner/__init__.py new file mode 100644 index 0000000..85112fe --- /dev/null +++ b/src/infuse_iot/task_runner/__init__.py @@ -0,0 +1,25 @@ +"""Task runner ctypes definitions and helpers.""" + +from infuse_iot.task_runner.codec import ( + decode_schedule_input, + encode_schedule, + format_schedule, + format_schedule_python, + parse_schedule, + raw_task_args, +) +from infuse_iot.task_runner.describe import format_description, state_name +from infuse_iot.task_runner.schedule import PeriodicityLockout, TaskSchedule + +__all__ = [ + "PeriodicityLockout", + "TaskSchedule", + "decode_schedule_input", + "encode_schedule", + "format_description", + "format_schedule_python", + "format_schedule", + "parse_schedule", + "raw_task_args", + "state_name", +] diff --git a/src/infuse_iot/task_runner/codec.py b/src/infuse_iot/task_runner/codec.py new file mode 100644 index 0000000..a0519e0 --- /dev/null +++ b/src/infuse_iot/task_runner/codec.py @@ -0,0 +1,384 @@ +"""Encoding, decoding, and raw formatting helpers for task schedules.""" + +import base64 +import binascii +import ctypes +import re + +from infuse_iot.generated import tasks +from infuse_iot.task_runner.describe import state_name, task_argument_option_class, task_class +from infuse_iot.task_runner.schedule import PeriodicityLockout, TaskSchedule + +VALIDITY_NAMES = { + TaskSchedule.Validity.ALWAYS: "TASK_VALID_ALWAYS", + TaskSchedule.Validity.ACTIVE: "TASK_VALID_ACTIVE", + TaskSchedule.Validity.INACTIVE: "TASK_VALID_INACTIVE", + TaskSchedule.Validity.PERMANENTLY_RUNS: "TASK_VALID_PERMANENTLY_RUNS", +} + +PERIODICITY_NAMES = { + TaskSchedule.Periodicity.FIXED: "TASK_PERIODICITY_FIXED", + TaskSchedule.Periodicity.LOCKOUT: "TASK_PERIODICITY_LOCKOUT", + TaskSchedule.Periodicity.AFTER: "TASK_PERIODICITY_AFTER", + TaskSchedule.Periodicity.LOCKOUT_DYNAMIC_BATTERY: "TASK_PERIODICITY_LOCKOUT_DYNAMIC_BATTERY", +} + +TASK_ID_NAMES = {value: name for name, value in tasks.TaskArguments.TASK_IDS.items()} + + +def _task_id_expression(task_id: int) -> str: + for cls_name, cls_value in vars(tasks).items(): + if not cls_name.startswith("Task") or not isinstance(cls_value, type): + continue + for attr_name in ("ID", "ALT1_ID", "ALT2_ID"): + if getattr(cls_value, attr_name, None) == task_id: + return f"tasks.{cls_name}.{attr_name}" + return str(task_id) + + +def _task_class_name(task_id: int) -> str | None: + task_expr = _task_id_expression(task_id) + if task_expr.startswith("tasks.") and "." in task_expr.removeprefix("tasks."): + return task_expr.split(".")[1] + return None + + +def _mask_expression(mask: int, option_class, class_expression: str) -> str: + if mask == 0 or option_class is None: + return f"0x{mask:02x}" + + remaining = mask + parts = [] + for name, value in vars(option_class).items(): + if not name.isupper() or not isinstance(value, int) or value == 0: + continue + if value & (value - 1): + continue + if mask & value == value: + parts.append(f"{class_expression}.{name}") + remaining &= ~value + + if remaining: + parts.append(f"0x{remaining:02x}") + return " | ".join(parts) if parts else f"0x{mask:02x}" + + +def _validity_expression(validity: int) -> str: + locked = validity & TaskSchedule.LOCKED + value = validity & TaskSchedule.Validity.MASK + names = { + TaskSchedule.Validity.ALWAYS: "TaskSchedule.Validity.ALWAYS", + TaskSchedule.Validity.ACTIVE: "TaskSchedule.Validity.ACTIVE", + TaskSchedule.Validity.INACTIVE: "TaskSchedule.Validity.INACTIVE", + TaskSchedule.Validity.PERMANENTLY_RUNS: "TaskSchedule.Validity.PERMANENTLY_RUNS", + } + expr = names.get(value, str(value)) + if locked: + expr = f"TaskSchedule.LOCKED | {expr}" + return expr + + +def _periodicity_expression(periodicity: int) -> str: + names = { + TaskSchedule.Periodicity.FIXED: "TaskSchedule.Periodicity.FIXED", + TaskSchedule.Periodicity.LOCKOUT: "TaskSchedule.Periodicity.LOCKOUT", + TaskSchedule.Periodicity.AFTER: "TaskSchedule.Periodicity.AFTER", + TaskSchedule.Periodicity.LOCKOUT_DYNAMIC_BATTERY: "TaskSchedule.Periodicity.LOCKOUT_DYNAMIC_BATTERY", + } + return names.get(periodicity, str(periodicity)) + + +def _validate_periodicity_type(periodicity: int) -> None: + if periodicity != 0 and periodicity not in PERIODICITY_NAMES: + raise ValueError(f"unknown periodicity_type {periodicity}") + + +def _lockout_expression(lockout_s: int) -> str: + if lockout_s & PeriodicityLockout.IGNORE_FIRST: + value = lockout_s & ~PeriodicityLockout.IGNORE_FIRST + if value: + return f"PeriodicityLockout.IGNORE_FIRST | {value}" + return "PeriodicityLockout.IGNORE_FIRST" + return str(lockout_s) + + +def _append_assignment(lines: list[str], target: str, value: int, expression: str | None = None) -> None: + if value == 0: + return + lines.append(f"{target} = {expression or value}") + + +def _append_array_assignments(lines: list[str], target: str, values) -> None: + for idx, value in enumerate(values): + _append_assignment(lines, f"{target}[{idx}]", value) + + +def _class_expression(cls) -> str: + return f"tasks.{cls.__qualname__}" + + +def _task_argument_expression(field_name: str, field_value, field_owner_type, root_task_class) -> str | None: + option_class = task_argument_option_class(field_name, field_owner_type, root_task_class) + if option_class is None: + return None + return _mask_expression(field_value, option_class, _class_expression(option_class)) + + +def _append_struct_assignments(lines: list[str], target: str, value, root_task_class) -> None: + for field_name, _field_type in getattr(value, "_fields_", []): + field_value = getattr(value, field_name) + field_target = f"{target}.{field_name}" + if hasattr(field_value, "_fields_"): + _append_struct_assignments(lines, field_target, field_value, root_task_class) + elif isinstance(field_value, ctypes.Array): + _append_array_assignments(lines, field_target, field_value) + else: + expression = _task_argument_expression(field_name, field_value, type(value), root_task_class) + _append_assignment(lines, field_target, field_value, expression) + + +def decode_schedule_input(value: str, expected_len: int | None = None) -> bytes: + """Decode a task schedule from a hex or base64 string.""" + compact = re.sub(r"[\s:_-]", "", value) + if compact.startswith(("0x", "0X")): + compact = compact[2:] + + candidates: list[tuple[str, bytes]] = [] + if compact and len(compact) % 2 == 0 and re.fullmatch(r"[0-9a-fA-F]+", compact): + candidates.append(("hex", bytes.fromhex(compact))) + + try: + candidates.append(("base64", base64.b64decode(value, validate=True))) + except binascii.Error: + pass + + if not candidates: + raise ValueError("input is neither an even-length hex string nor valid base64") + + expected = ctypes.sizeof(TaskSchedule) if expected_len is None else expected_len + for _encoding, data in candidates: + if len(data) == expected: + return data + return candidates[0][1] + + +def parse_schedule(value: str | bytes | bytearray) -> TaskSchedule: + """Parse a task schedule from encoded text or raw bytes.""" + if isinstance(value, str): + data = decode_schedule_input(value) + else: + data = bytes(value) + + expected = ctypes.sizeof(TaskSchedule) + if len(data) != expected: + raise ValueError(f"decoded {len(data)} bytes, expected {expected}") + return TaskSchedule.from_buffer_copy(data) + + +def encode_schedule(schedule: TaskSchedule, encoding: str = "hex") -> str: + """Encode a task schedule as hex or base64 text.""" + payload = bytes(schedule) + if encoding == "hex": + return payload.hex() + if encoding == "base64": + return base64.b64encode(payload).decode("ascii") + raise ValueError("encoding must be 'hex' or 'base64'") + + +def scalar_value(value): + if isinstance(value, ctypes.Array): + return list(value) + return value + + +def format_struct(obj, indent: int = 0) -> list[str]: + lines = [] + prefix = " " * indent + fields = getattr(obj, "_fields_", []) + if not fields: + return [f"{prefix}(no arguments)"] + + for name, _field_type in fields: + value = getattr(obj, name) + if hasattr(value, "_fields_"): + lines.append(f"{prefix}{name}:") + lines.extend(format_struct(value, indent + 2)) + else: + lines.append(f"{prefix}{name}: {scalar_value(value)}") + return lines + + +def raw_task_args(schedule: TaskSchedule) -> bytes: + return ctypes.string_at(ctypes.addressof(schedule.task_args), ctypes.sizeof(schedule.task_args)) + + +def format_state_conditions(states, empty_default: bool, indent: int = 4) -> list[str]: + prefix = " " * indent + state_ids = list(states.states) + lines = [ + f"{prefix}metadata: 0x{states.metadata:02x}", + f"{prefix}raw_states: {state_ids}", + ] + + if state_ids[0] == 0: + lines.append(f"{prefix}decoded: no states configured, evaluates to {empty_default}") + return lines + + terms = [] + for idx, state_id in enumerate(state_ids): + if state_id == 0: + break + inverted = bool(states.metadata & (1 << idx)) + operator = "OR" if states.metadata & (1 << (idx + 4)) else "AND" + term = f"{'NOT ' if inverted else ''}{state_name(state_id)} ({state_id})" + terms.append((operator, term)) + lines.append(f"{prefix}[{idx}] {operator} {term}") + + initial = "false" if terms[0][0] == "OR" else "true" + expression = " ".join(f"{operator} {term}" for operator, term in terms) + lines.append(f"{prefix}decoded: initial={initial}; {expression}") + return lines + + +def format_schedule(schedule: TaskSchedule, source_len: int | None = None) -> str: + """Format every raw task schedule field.""" + _validate_periodicity_type(schedule.periodicity_type) + task_name = TASK_ID_NAMES.get(schedule.task_id, "unknown") + validity = VALIDITY_NAMES.get(schedule.validity & TaskSchedule.Validity.MASK, "unknown") + locked = bool(schedule.validity & TaskSchedule.LOCKED) + periodicity = PERIODICITY_NAMES.get(schedule.periodicity_type, "unknown") + arg_field = tasks.TaskArguments.TASK_ARG_FIELDS.get(schedule.task_id) + source_size = ctypes.sizeof(TaskSchedule) if source_len is None else source_len + + lines = [ + "Task Schedule", + f" source_size: {source_size} bytes", + f" ctypes_size: {ctypes.sizeof(TaskSchedule)} bytes", + f" task_id: {schedule.task_id} ({task_name})", + f" validity: 0x{schedule.validity:02x} ({validity}, locked={locked})", + f" periodicity_type: {schedule.periodicity_type} ({periodicity})", + f" boot_lockout_minutes: {schedule.boot_lockout_minutes}", + f" timeout_s: {schedule.timeout_s}", + " battery_start:", + f" lower: {schedule.battery_start.lower}", + f" upper: {schedule.battery_start.upper}", + " battery_terminate:", + f" lower: {schedule.battery_terminate.lower}", + f" upper: {schedule.battery_terminate.upper}", + " periodicity:", + ] + + if schedule.periodicity_type == TaskSchedule.Periodicity.FIXED: + lines.append(f" fixed.period_s: {schedule.periodicity.fixed.period_s}") + elif schedule.periodicity_type == TaskSchedule.Periodicity.LOCKOUT: + lines.append(f" lockout.lockout_s: {schedule.periodicity.lockout.lockout_s}") + elif schedule.periodicity_type == TaskSchedule.Periodicity.AFTER: + lines.append(f" after.schedule_idx: {schedule.periodicity.after.schedule_idx}") + lines.append(f" after.duration_s: {schedule.periodicity.after.duration_s}") + elif schedule.periodicity_type == TaskSchedule.Periodicity.LOCKOUT_DYNAMIC_BATTERY: + value = schedule.periodicity.lockout_dynamic_battery + lines.extend( + [ + f" lockout_dynamic_battery.lockout_min: {value.lockout_min}", + f" lockout_dynamic_battery.lockout_max: {value.lockout_max}", + f" lockout_dynamic_battery.battery_min: {value.battery_min}", + f" lockout_dynamic_battery.battery_max: {value.battery_max}", + ] + ) + else: + lines.append(f" raw: {bytes(schedule.periodicity).hex()}") + + lines.extend( + [ + f" states_start_timeout_2x_s: {schedule.states_start_timeout_2x_s}", + ] + ) + lines.append(" states_start:") + lines.extend(format_state_conditions(schedule.states_start, True, 4)) + lines.append(" states_terminate:") + lines.extend(format_state_conditions(schedule.states_terminate, False, 4)) + lines.append(" task_logging:") + for idx, logging in enumerate(schedule.task_logging): + lines.append(f" [{idx}] loggers=0x{logging.loggers:02x}, tdf_mask=0x{logging.tdf_mask:02x}") + + lines.append(f" task_args_raw: {raw_task_args(schedule).hex()}") + if arg_field is None: + lines.append(" task_args: unknown task_id") + else: + lines.append(f" task_args ({arg_field}):") + lines.extend(format_struct(getattr(schedule.task_args, arg_field), 4)) + + return "\n".join(lines) + + +def format_schedule_python(schedule: TaskSchedule) -> str: + """Format a task schedule as Python assignment lines.""" + _validate_periodicity_type(schedule.periodicity_type) + lines = ["schedule = TaskSchedule()"] + + _append_assignment(lines, "schedule.task_id", schedule.task_id, _task_id_expression(schedule.task_id)) + _append_assignment(lines, "schedule.validity", schedule.validity, _validity_expression(schedule.validity)) + _append_assignment( + lines, + "schedule.periodicity_type", + schedule.periodicity_type, + _periodicity_expression(schedule.periodicity_type), + ) + _append_assignment(lines, "schedule.boot_lockout_minutes", schedule.boot_lockout_minutes) + _append_assignment(lines, "schedule.timeout_s", schedule.timeout_s) + _append_assignment(lines, "schedule.battery_start.lower", schedule.battery_start.lower) + _append_assignment(lines, "schedule.battery_start.upper", schedule.battery_start.upper) + _append_assignment(lines, "schedule.battery_terminate.lower", schedule.battery_terminate.lower) + _append_assignment(lines, "schedule.battery_terminate.upper", schedule.battery_terminate.upper) + + if schedule.periodicity_type == TaskSchedule.Periodicity.FIXED: + _append_assignment(lines, "schedule.periodicity.fixed.period_s", schedule.periodicity.fixed.period_s) + elif schedule.periodicity_type == TaskSchedule.Periodicity.LOCKOUT: + _append_assignment( + lines, + "schedule.periodicity.lockout.lockout_s", + schedule.periodicity.lockout.lockout_s, + _lockout_expression(schedule.periodicity.lockout.lockout_s), + ) + elif schedule.periodicity_type == TaskSchedule.Periodicity.AFTER: + _append_assignment(lines, "schedule.periodicity.after.schedule_idx", schedule.periodicity.after.schedule_idx) + _append_assignment(lines, "schedule.periodicity.after.duration_s", schedule.periodicity.after.duration_s) + elif schedule.periodicity_type == TaskSchedule.Periodicity.LOCKOUT_DYNAMIC_BATTERY: + value = schedule.periodicity.lockout_dynamic_battery + _append_assignment(lines, "schedule.periodicity.lockout_dynamic_battery.lockout_min", value.lockout_min) + _append_assignment(lines, "schedule.periodicity.lockout_dynamic_battery.lockout_max", value.lockout_max) + _append_assignment(lines, "schedule.periodicity.lockout_dynamic_battery.battery_min", value.battery_min) + _append_assignment(lines, "schedule.periodicity.lockout_dynamic_battery.battery_max", value.battery_max) + + _append_assignment(lines, "schedule.states_start_timeout_2x_s", schedule.states_start_timeout_2x_s) + _append_assignment(lines, "schedule.states_start.metadata", schedule.states_start.metadata) + _append_array_assignments(lines, "schedule.states_start.states", schedule.states_start.states) + _append_assignment(lines, "schedule.states_terminate.metadata", schedule.states_terminate.metadata) + _append_array_assignments(lines, "schedule.states_terminate.states", schedule.states_terminate.states) + + for idx, logging in enumerate(schedule.task_logging): + task_class_name = _task_class_name(schedule.task_id) + logging_class = tasks.TaskArguments.TASK_LOGGING_CLASSES.get(schedule.task_id) + tdf_mask_expression = ( + _mask_expression(logging.tdf_mask, logging_class, f"tasks.{task_class_name}.Logging") + if task_class_name is not None + else f"0x{logging.tdf_mask:02x}" + ) + _append_assignment( + lines, + f"schedule.task_logging[{idx}].loggers", + logging.loggers, + _mask_expression(logging.loggers, tasks.TdfDataLogger, "tasks.TdfDataLogger"), + ) + _append_assignment(lines, f"schedule.task_logging[{idx}].tdf_mask", logging.tdf_mask, tdf_mask_expression) + + arg_field = tasks.TaskArguments.TASK_ARG_FIELDS.get(schedule.task_id) + if arg_field is None: + for idx, value in enumerate(raw_task_args(schedule)): + _append_assignment(lines, f"schedule.task_args.raw[{idx}]", value, f"0x{value:02x}") + else: + root_task_class = task_class(schedule.task_id) + task_args = getattr(schedule.task_args, arg_field) + _append_struct_assignments(lines, f"schedule.task_args.{arg_field}", task_args, root_task_class) + + return "\n".join(lines) diff --git a/src/infuse_iot/task_runner/describe.py b/src/infuse_iot/task_runner/describe.py new file mode 100644 index 0000000..e148409 --- /dev/null +++ b/src/infuse_iot/task_runner/describe.py @@ -0,0 +1,376 @@ +"""Human-readable descriptions of task schedule behavior.""" + +from infuse_iot.generated import tasks +from infuse_iot.task_runner.schedule import PeriodicityLockout, TaskSchedule + +TASK_ID_NAMES = {value: name for name, value in tasks.TaskArguments.TASK_IDS.items()} + + +def task_class(task_id: int): + for cls_name, cls_value in vars(tasks).items(): + if not cls_name.startswith("Task") or not isinstance(cls_value, type): + continue + for attr_name in ("ID", "ALT1_ID", "ALT2_ID"): + if getattr(cls_value, attr_name, None) == task_id: + return cls_value + return None + + +def task_argument_field(task_id: int) -> str | None: + return tasks.TaskArguments.TASK_ARG_FIELDS.get(task_id) + + +def task_logging_class(task_id: int): + return tasks.TaskArguments.TASK_LOGGING_CLASSES.get(task_id) + + +class InfuseState: + REBOOTING = 1 + APPLICATION_ACTIVE = 2 + TIME_KNOWN = 3 + DEVICE_STATIONARY = 4 + HIGH_PRIORITY_UPLINK = 5 + DEVICE_STARTED_MOVING = 6 + DEVICE_STOPPED_MOVING = 7 + LED_SUPPRESS = 8 + DEVICE_MOVING = 9 + APP_START = 128 + END = 255 + + NAMES = { + REBOOTING: "REBOOTING", + APPLICATION_ACTIVE: "APPLICATION_ACTIVE", + TIME_KNOWN: "TIME_KNOWN", + DEVICE_STATIONARY: "DEVICE_STATIONARY", + HIGH_PRIORITY_UPLINK: "HIGH_PRIORITY_UPLINK", + DEVICE_STARTED_MOVING: "DEVICE_STARTED_MOVING", + DEVICE_STOPPED_MOVING: "DEVICE_STOPPED_MOVING", + LED_SUPPRESS: "LED_SUPPRESS", + DEVICE_MOVING: "DEVICE_MOVING", + APP_START: "INFUSE_STATES_APP_START", + END: "INFUSE_STATES_END", + } + + +def state_name(state_id: int) -> str: + if state_id in InfuseState.NAMES: + return InfuseState.NAMES[state_id] + if InfuseState.APP_START <= state_id < InfuseState.END: + return f"application_state_{state_id}" + return "unknown" + + +def state_condition_phrase(states, empty_default: bool) -> str: + state_ids = list(states.states) + if state_ids[0] == 0: + return "no state condition is configured" if empty_default else "no stop state is configured" + + terms = [] + for idx, state_id in enumerate(state_ids): + if state_id == 0: + break + inverted = bool(states.metadata & (1 << idx)) + operator = "or" if states.metadata & (1 << (idx + 4)) else "and" + phrase = f"{state_name(state_id)} is {'not set' if inverted else 'set'}" + terms.append((operator, phrase)) + + if not terms: + return "no state condition is configured" if empty_default else "no stop state is configured" + + text = terms[0][1] + for operator, phrase in terms[1:]: + text = f"[{text} {operator} {phrase}]" + return text + + +def periodicity_start_phrase(schedule: TaskSchedule) -> str | None: + if schedule.periodicity_type == 0: + return None + if schedule.periodicity_type == TaskSchedule.Periodicity.FIXED: + return f"the epoch time is on a {schedule.periodicity.fixed.period_s} second boundary" + if schedule.periodicity_type == TaskSchedule.Periodicity.LOCKOUT: + lockout = schedule.periodicity.lockout.lockout_s + ignore_first = bool(lockout & PeriodicityLockout.IGNORE_FIRST) + lockout &= ~PeriodicityLockout.IGNORE_FIRST + phrase = f"at least {lockout} seconds have elapsed since the task last started" + if ignore_first: + phrase += ", except the first run may start immediately after boot" + else: + phrase += " or the application booted" + return phrase + if schedule.periodicity_type == TaskSchedule.Periodicity.AFTER: + after = schedule.periodicity.after + return f"schedule index {after.schedule_idx} terminated exactly {after.duration_s} seconds ago" + if schedule.periodicity_type == TaskSchedule.Periodicity.LOCKOUT_DYNAMIC_BATTERY: + ldb = schedule.periodicity.lockout_dynamic_battery + return ( + "the dynamic battery lockout has elapsed " + f"({ldb.lockout_min}s at <= {ldb.battery_min}% SoC, " + f"{ldb.lockout_max}s at >= {ldb.battery_max}% SoC, linearly scaled between)" + ) + return f"the periodicity condition for unknown type {schedule.periodicity_type} passes" + + +def start_battery_phrase(schedule: TaskSchedule) -> str | None: + lower = schedule.battery_start.lower + upper = schedule.battery_start.upper + parts = [] + if lower: + parts.append(f"SoC is at least {lower}%") + if upper: + parts.append(f"SoC is at most {upper}%") + if not parts: + return None + return " and ".join(parts) + + +def terminate_battery_phrases(schedule: TaskSchedule) -> list[str]: + parts = [] + if schedule.battery_terminate.lower: + parts.append(f"SoC falls to {schedule.battery_terminate.lower}% or below") + if schedule.battery_terminate.upper: + parts.append(f"SoC rises to {schedule.battery_terminate.upper}% or above") + return parts + + +def start_state_phrase(schedule: TaskSchedule) -> str | None: + if list(schedule.states_start.states)[0] == 0: + return None + + phrase = state_condition_phrase(schedule.states_start, True) + if schedule.states_start_timeout_2x_s: + timeout = 2 * schedule.states_start_timeout_2x_s + return f"{phrase}, or {timeout} seconds have elapsed since last start" + return phrase + + +def stop_state_phrase(schedule: TaskSchedule) -> str | None: + if list(schedule.states_terminate.states)[0] == 0: + return None + return state_condition_phrase(schedule.states_terminate, False) + + +def start_condition_phrases(schedule: TaskSchedule) -> list[str]: + validity = schedule.validity & TaskSchedule.Validity.MASK + conditions = [] + + if validity == TaskSchedule.Validity.ACTIVE: + conditions.append("APPLICATION_ACTIVE is set") + elif validity == TaskSchedule.Validity.INACTIVE: + conditions.append("APPLICATION_ACTIVE is cleared") + + if schedule.boot_lockout_minutes: + conditions.append(f"uptime is at least {schedule.boot_lockout_minutes} minute(s)") + + periodicity_start = periodicity_start_phrase(schedule) + if periodicity_start: + conditions.append(periodicity_start) + + battery_start = start_battery_phrase(schedule) + if battery_start: + conditions.append(battery_start) + + state_start = start_state_phrase(schedule) + if state_start: + conditions.append(state_start) + + return conditions + + +def stop_condition_phrases(schedule: TaskSchedule) -> list[str]: + validity = schedule.validity & TaskSchedule.Validity.MASK + conditions = [] + + if validity == TaskSchedule.Validity.ACTIVE: + conditions.append("APPLICATION_ACTIVE is cleared") + elif validity == TaskSchedule.Validity.INACTIVE: + conditions.append("APPLICATION_ACTIVE is set") + + if schedule.timeout_s: + conditions.append(f"the task has run for at least {schedule.timeout_s} seconds") + + conditions.extend(terminate_battery_phrases(schedule)) + + state_stop = stop_state_phrase(schedule) + if state_stop: + conditions.append(state_stop) + + return conditions + + +def is_set_value(value) -> bool: + if hasattr(value, "_fields_"): + return any(is_set_value(getattr(value, name)) for name, _field_type in value._fields_) + return value != 0 + + +def option_mask_names(mask: int, option_class) -> str | None: + if option_class is None: + return None + + names = [] + known_mask = 0 + for name, value in vars(option_class).items(): + if not name.isupper() or not isinstance(value, int) or value == 0: + continue + if value & (value - 1): + continue + known_mask |= value + if mask & value: + names.append(name) + + unknown_mask = mask & ~known_mask + if unknown_mask: + names.append(f"unknown_bits=0x{unknown_mask:02x}") + + if not names: + return None + return ", ".join(names) + + +def task_argument_option_class(field_name: str, field_type, root_task_class): + if field_name == "flags": + class_names = ("Flags",) + elif field_name == "loggers": + return tasks.TdfDataLogger + elif field_name in ("constellations", "tdfs"): + class_names = ("".join(part.capitalize() for part in field_name.split("_")),) + else: + return None + + for class_name in class_names: + option_class = getattr(field_type, class_name, None) + if option_class is not None: + return option_class + option_class = getattr(root_task_class, class_name, None) + if option_class is not None: + return option_class + return None + + +def task_argument_value_line(field_name: str, field_value, field_owner_type, root_task_class) -> str: + option_class = task_argument_option_class( + field_name.rsplit(".", maxsplit=1)[-1], + field_owner_type, + root_task_class, + ) + if option_class is None: + return f"{field_name}: {field_value}" + + names = option_mask_names(field_value, option_class) + suffix = f" ({names})" if names else "" + return f"{field_name}: 0x{field_value:02x}{suffix}" + + +def task_argument_lines(value, root_task_class, prefix: str = "") -> list[str]: + lines = [] + for name, _field_type in getattr(value, "_fields_", []): + field_value = getattr(value, name) + field_name = f"{prefix}.{name}" if prefix else name + if hasattr(field_value, "_fields_"): + lines.extend(task_argument_lines(field_value, root_task_class, field_name)) + elif is_set_value(field_value): + lines.append(task_argument_value_line(field_name, field_value, type(value), root_task_class)) + return lines + + +def schedule_task_argument_lines(schedule: TaskSchedule) -> list[str]: + arg_field = task_argument_field(schedule.task_id) + root_task_class = task_class(schedule.task_id) + if arg_field is None or root_task_class is None: + return ["unknown task_id"] + return task_argument_lines(getattr(schedule.task_args, arg_field), root_task_class) + + +def schedule_logging_lines(schedule: TaskSchedule) -> list[str]: + logging_class = task_logging_class(schedule.task_id) + lines = [] + + for idx, logging_config in enumerate(schedule.task_logging): + if not logging_config.loggers and not logging_config.tdf_mask: + continue + + logger_names = option_mask_names(logging_config.loggers, tasks.TdfDataLogger) + logger_suffix = f" ({logger_names})" if logger_names else "" + lines.append(f"[{idx}].loggers: 0x{logging_config.loggers:02x}{logger_suffix}") + + mask_names = option_mask_names(logging_config.tdf_mask, logging_class) + mask_suffix = f" ({mask_names})" if mask_names else "" + lines.append(f"[{idx}].tdf_mask: 0x{logging_config.tdf_mask:02x}{mask_suffix}") + + return lines + + +def append_logging_configuration(lines: list[str], schedule: TaskSchedule) -> None: + logging_values = schedule_logging_lines(schedule) + lines.extend(["", "Logging configuration:"]) + if logging_values: + for value in logging_values: + lines.append(f" - {value}") + else: + lines.append(" No task logging is configured.") + + +def append_task_arguments(lines: list[str], schedule: TaskSchedule) -> None: + task_argument_values = schedule_task_argument_lines(schedule) + lines.extend(["", "Task arguments:"]) + if task_argument_values: + for value in task_argument_values: + lines.append(f" - {value}") + else: + lines.append(" No task-specific arguments are set.") + + +def format_description(schedule: TaskSchedule) -> str: + task_name = TASK_ID_NAMES.get(schedule.task_id, "unknown") + locked = " locked against KV-store updates" if schedule.validity & TaskSchedule.LOCKED else "" + validity = schedule.validity & TaskSchedule.Validity.MASK + lines = [ + f"Task {schedule.task_id} ({task_name}){locked}.", + "", + ] + + if validity == 0 or validity >= TaskSchedule.Validity.END: + lines.append( + f"Schedule is invalid: validity value 0x{schedule.validity:02x} " + f"masks to {validity}, outside 1..{TaskSchedule.Validity.END - 1}." + ) + append_logging_configuration(lines, schedule) + append_task_arguments(lines, schedule) + return "\n".join(lines) + + lines.append("Start conditions:") + + if validity == TaskSchedule.Validity.PERMANENTLY_RUNS: + lines.append(" The task starts or restarts whenever it is not running.") + lines.extend( + [ + "", + "Stop conditions:", + " Normal stop conditions are not evaluated for permanently-running tasks.", + ] + ) + append_logging_configuration(lines, schedule) + append_task_arguments(lines, schedule) + return "\n".join(lines) + + start_conditions = start_condition_phrases(schedule) + if start_conditions: + lines.append(" All of these must be true:") + else: + lines.append(" No schedule start conditions are configured.") + for condition in start_conditions: + lines.append(f" - {condition}.") + + stop_conditions = stop_condition_phrases(schedule) + lines.extend(["", "Stop conditions:"]) + if stop_conditions: + lines.append(" Any of these will request the task to stop:") + else: + lines.append(" No schedule stop conditions are configured.") + for condition in stop_conditions: + lines.append(f" - {condition}.") + + append_logging_configuration(lines, schedule) + append_task_arguments(lines, schedule) + return "\n".join(lines) diff --git a/src/infuse_iot/task_runner/schedule.py b/src/infuse_iot/task_runner/schedule.py new file mode 100644 index 0000000..edb1c2b --- /dev/null +++ b/src/infuse_iot/task_runner/schedule.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""ctypes mirror of ``struct task_schedule`` from ``schedule.h``. + +This file depends on ``infuse_iot.generated.tasks`` for the task-specific +argument union. +""" + +import ctypes + +from infuse_iot.generated.tasks import TaskArguments + + +class TaskScheduleTdfLogging(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("loggers", ctypes.c_uint8), + ("tdf_mask", ctypes.c_uint8), + ] + + +class TaskScheduleStateConditions(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("metadata", ctypes.c_uint8), + ("states", ctypes.c_uint8 * 4), + ] + + +class BatteryStartThresholds(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("lower", ctypes.c_uint8), + ("upper", ctypes.c_uint8), + ] + + +class BatteryTerminateThresholds(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("lower", ctypes.c_uint8), + ("upper", ctypes.c_uint8), + ] + + +class PeriodicityPeriodic(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("period_s", ctypes.c_uint32), + ] + + +class PeriodicityLockout(ctypes.LittleEndianStructure): + IGNORE_FIRST = 1 << 31 + + _pack_ = 1 + _fields_ = [ + ("lockout_s", ctypes.c_uint32), + ] + + +class PeriodicityAfter(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("schedule_idx", ctypes.c_uint8), + ("duration_s", ctypes.c_uint16), + ] + + +class PeriodicityLockoutDynamicBattery(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("lockout_min", ctypes.c_uint16), + ("lockout_max", ctypes.c_uint16), + ("battery_min", ctypes.c_uint8), + ("battery_max", ctypes.c_uint8), + ] + + +class PeriodicityArgs(ctypes.Union): + _pack_ = 1 + _fields_ = [ + ("fixed", PeriodicityPeriodic), + ("lockout", PeriodicityLockout), + ("after", PeriodicityAfter), + ("lockout_dynamic_battery", PeriodicityLockoutDynamicBattery), + ] + + +class TaskSchedule(ctypes.LittleEndianStructure): + LOCKED = 0x80 + + class Validity: + ALWAYS = 1 + ACTIVE = 2 + INACTIVE = 3 + PERMANENTLY_RUNS = 4 + END = 5 + MASK = 0x7F + + class Periodicity: + FIXED = 1 + LOCKOUT = 2 + AFTER = 3 + LOCKOUT_DYNAMIC_BATTERY = 4 + + EXPECTED_SIZE = 51 + + _pack_ = 1 + _fields_ = [ + ("task_id", ctypes.c_uint8), + ("validity", ctypes.c_uint8), + ("periodicity_type", ctypes.c_uint8), + ("boot_lockout_minutes", ctypes.c_uint8), + ("timeout_s", ctypes.c_uint32), + ("battery_start", BatteryStartThresholds), + ("battery_terminate", BatteryTerminateThresholds), + ("periodicity", PeriodicityArgs), + ("states_start_timeout_2x_s", ctypes.c_uint16), + ("states_start", TaskScheduleStateConditions), + ("states_terminate", TaskScheduleStateConditions), + ("task_logging", TaskScheduleTdfLogging * 2), + ("task_args", TaskArguments), + ] + + +def _field_offsets(struct_type): + return {field_name: getattr(struct_type, field_name).offset for field_name, _field_type in struct_type._fields_} + + +if __name__ == "__main__": + print(f"TaskSchedule size: {ctypes.sizeof(TaskSchedule)} bytes") + print(f"Expected size: {TaskSchedule.EXPECTED_SIZE} bytes") + print("Field offsets:") + for name, offset in _field_offsets(TaskSchedule).items(): + print(f" {name}: {offset}") From a5410b1c4961504cefc942ce543b07fa86e81482 Mon Sep 17 00:00:00 2001 From: Jordan Yates Date: Thu, 6 Aug 2026 13:40:01 +1000 Subject: [PATCH 2/3] tools: schedule: added Add a helper tool for decoding and understanding schedule blobs Signed-off-by: Jordan Yates --- src/infuse_iot/tools/registry.py | 6 + src/infuse_iot/tools/schedule.py | 42 +++++++ tests/test_schedule_tool.py | 194 +++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 src/infuse_iot/tools/schedule.py create mode 100644 tests/test_schedule_tool.py diff --git a/src/infuse_iot/tools/registry.py b/src/infuse_iot/tools/registry.py index 1257082..53f69fa 100644 --- a/src/infuse_iot/tools/registry.py +++ b/src/infuse_iot/tools/registry.py @@ -166,6 +166,12 @@ def load_extension_tools(path: str | pathlib.Path) -> tuple[ToolSpec, ...]: description="Manage remote procedure calls through Infuse-IoT cloud", module="infuse_iot.tools.rpc_cloud", ), + ToolSpec( + name="schedule", + help="Task schedule utilities", + description="Task schedule utilities", + module="infuse_iot.tools.schedule", + ), ToolSpec( name="serial_throughput", help="Test serial throughput to local gateway", diff --git a/src/infuse_iot/tools/schedule.py b/src/infuse_iot/tools/schedule.py new file mode 100644 index 0000000..eac3a87 --- /dev/null +++ b/src/infuse_iot/tools/schedule.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +"""Task schedule utilities.""" + +import argparse +import sys + +from infuse_iot.task_runner import format_description, format_schedule_python, parse_schedule + + +class SubCommand: + @classmethod + def add_parser(cls, parser: argparse.ArgumentParser): + subcommands = parser.add_subparsers(title="schedule commands", metavar="", required=True) + + decode = subcommands.add_parser( + "decode", + help="Decode a task schedule", + description="Decode a task schedule from hex or base64", + ) + decode.add_argument("--python", action="store_true", help="output Python assignment lines") + decode.add_argument("schedule", help="task schedule encoded as hex or base64") + decode.set_defaults(schedule_command="decode") + + def __init__(self, args: argparse.Namespace): + self._args = args + + def run(self) -> None: + if self._args.schedule_command == "decode": + self._run_decode() + + def _run_decode(self) -> None: + try: + schedule = parse_schedule(self._args.schedule) + except ValueError as err: + print(f"error: {err}", file=sys.stderr) + raise SystemExit(2) from None + + if self._args.python: + print(format_schedule_python(schedule)) + else: + print(format_description(schedule)) diff --git a/tests/test_schedule_tool.py b/tests/test_schedule_tool.py new file mode 100644 index 0000000..53a46e6 --- /dev/null +++ b/tests/test_schedule_tool.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 + +import pytest + +from infuse_iot.app.main import InfuseApp +from infuse_iot.generated import tasks +from infuse_iot.task_runner import ( + PeriodicityLockout, + TaskSchedule, + encode_schedule, + format_description, + format_schedule, + format_schedule_python, +) + + +def _example_schedule() -> TaskSchedule: + schedule = TaskSchedule() + + schedule.task_id = tasks.TaskBattery.ID + schedule.validity = TaskSchedule.Validity.ACTIVE + schedule.periodicity_type = TaskSchedule.Periodicity.LOCKOUT + schedule.boot_lockout_minutes = 5 + schedule.timeout_s = 30 + + schedule.battery_start.lower = 20 + schedule.battery_terminate.lower = 10 + schedule.periodicity.lockout.lockout_s = PeriodicityLockout.IGNORE_FIRST | 3600 + schedule.task_logging[0].loggers = tasks.TdfDataLogger.FLASH_ONBOARD + schedule.task_logging[0].tdf_mask = tasks.TaskBattery.Logging.SOC + schedule.task_args.battery.repeat_interval_ms = 1000 + + return schedule + + +def test_format_schedule_python_reconstructs_schedule(): + schedule = _example_schedule() + output = format_schedule_python(schedule) + namespace = { + "PeriodicityLockout": PeriodicityLockout, + "TaskSchedule": TaskSchedule, + "tasks": tasks, + } + + exec(output, namespace) + + rebuilt = namespace["schedule"] + assert isinstance(rebuilt, TaskSchedule) + assert bytes(rebuilt) == bytes(schedule) + assert "schedule.task_id = tasks.TaskBattery.ID" in output + assert "schedule.task_logging[0].tdf_mask = tasks.TaskBattery.Logging.SOC" in output + + +def test_format_schedule_python_reconstructs_unknown_task_raw_args(): + data = bytearray(bytes(TaskSchedule())) + data[0] = 250 + data[-1] = 0x5A + schedule = TaskSchedule.from_buffer_copy(data) + + output = format_schedule_python(schedule) + + assert_python_output_rebuilds_schedule(output, schedule) + assert "schedule.task_args.raw[16] = 0x5a" in output + + +def test_schedule_formatters_fault_unknown_periodicity_type(): + data = bytearray(bytes(TaskSchedule())) + data[2] = 99 + data[12:18] = b"abcdef" + schedule = TaskSchedule.from_buffer_copy(data) + + with pytest.raises(ValueError, match="unknown periodicity_type 99"): + format_schedule(schedule) + with pytest.raises(ValueError, match="unknown periodicity_type 99"): + format_schedule_python(schedule) + + +def assert_python_output_rebuilds_schedule(output: str, schedule: TaskSchedule): + namespace = { + "PeriodicityLockout": PeriodicityLockout, + "TaskSchedule": TaskSchedule, + "tasks": tasks, + } + + exec(output, namespace) + + rebuilt = namespace["schedule"] + assert isinstance(rebuilt, TaskSchedule) + assert bytes(rebuilt) == bytes(schedule) + + +def test_schedule_decode_defaults_to_description(capsys, monkeypatch): + schedule = _example_schedule() + monkeypatch.setattr("infuse_iot.app.main.get_custom_tool_path", lambda: None) + + InfuseApp().run(["schedule", "decode", encode_schedule(schedule)]) + + output = capsys.readouterr().out + assert "Task 2 (battery)." in output + assert "Start conditions:" in output + + +def test_schedule_decode_python(capsys, monkeypatch): + schedule = _example_schedule() + monkeypatch.setattr("infuse_iot.app.main.get_custom_tool_path", lambda: None) + + InfuseApp().run(["schedule", "decode", "--python", encode_schedule(schedule)]) + + output = capsys.readouterr().out + assert "schedule = TaskSchedule()" in output + assert "schedule.periodicity.lockout.lockout_s = PeriodicityLockout.IGNORE_FIRST | 3600" in output + + +def test_description_names_task_argument_bitfields(): + schedule = TaskSchedule() + schedule.task_id = tasks.TaskGnss.ID + schedule.validity = TaskSchedule.Validity.ALWAYS + schedule.periodicity_type = TaskSchedule.Periodicity.FIXED + schedule.periodicity.fixed.period_s = 60 + schedule.task_args.gnss.constellations = tasks.TaskGnss.Constellations.GPS | tasks.TaskGnss.Constellations.GALILEO + schedule.task_args.gnss.flags = tasks.TaskGnss.Flags.RUN_TO_LOCATION_FIX | tasks.TaskGnss.Flags.PERFORMANCE_MODE + + output = format_description(schedule) + + assert "constellations: 0x05 (GPS, GALILEO)" in output + assert "flags: 0x81 (RUN_TO_LOCATION_FIX, PERFORMANCE_MODE)" in output + + +def test_format_schedule_python_names_task_argument_bitfields(): + schedule = TaskSchedule() + schedule.task_id = tasks.TaskGnss.ID + schedule.validity = TaskSchedule.Validity.ALWAYS + schedule.periodicity_type = TaskSchedule.Periodicity.FIXED + schedule.periodicity.fixed.period_s = 60 + schedule.task_args.gnss.constellations = tasks.TaskGnss.Constellations.GPS | tasks.TaskGnss.Constellations.GALILEO + schedule.task_args.gnss.flags = tasks.TaskGnss.Flags.RUN_TO_LOCATION_FIX | tasks.TaskGnss.Flags.PERFORMANCE_MODE + + output = format_schedule_python(schedule) + + assert_python_output_rebuilds_schedule(output, schedule) + assert ( + "schedule.task_args.gnss.constellations = " + "tasks.TaskGnss.Constellations.GPS | tasks.TaskGnss.Constellations.GALILEO" + ) in output + assert ( + "schedule.task_args.gnss.flags = " + "tasks.TaskGnss.Flags.RUN_TO_LOCATION_FIX | tasks.TaskGnss.Flags.PERFORMANCE_MODE" + ) in output + assert "RUN_MASK" not in output + + +def test_description_names_nested_task_argument_bitfields(): + schedule = TaskSchedule() + schedule.task_id = tasks.TaskNetworkScan.ID + schedule.validity = TaskSchedule.Validity.ALWAYS + schedule.periodicity_type = TaskSchedule.Periodicity.FIXED + schedule.periodicity.fixed.period_s = 60 + schedule.task_args.network_scan.flags = ( + tasks.TaskNetworkScan.Flags.LTE_CELLS | tasks.TaskNetworkScan.Flags.SKIP_LTE_IF_WIFI_GOOD + ) + schedule.task_args.network_scan.wifi.flags = ( + tasks.TaskNetworkScan.WifiArgs.Flags.INCLUDE_DUPLICATES | tasks.TaskNetworkScan.WifiArgs.Flags.SCAN_ACTIVE + ) + + output = format_description(schedule) + + assert "flags: 0x81 (LTE_CELLS, SKIP_LTE_IF_WIFI_GOOD)" in output + assert "wifi.flags: 0x05 (INCLUDE_DUPLICATES, SCAN_ACTIVE)" in output + + +def test_format_schedule_python_names_nested_task_argument_bitfields(): + schedule = TaskSchedule() + schedule.task_id = tasks.TaskNetworkScan.ID + schedule.validity = TaskSchedule.Validity.ALWAYS + schedule.periodicity_type = TaskSchedule.Periodicity.FIXED + schedule.periodicity.fixed.period_s = 60 + schedule.task_args.network_scan.flags = ( + tasks.TaskNetworkScan.Flags.LTE_CELLS | tasks.TaskNetworkScan.Flags.SKIP_LTE_IF_WIFI_GOOD + ) + schedule.task_args.network_scan.wifi.flags = ( + tasks.TaskNetworkScan.WifiArgs.Flags.INCLUDE_DUPLICATES | tasks.TaskNetworkScan.WifiArgs.Flags.SCAN_ACTIVE + ) + + output = format_schedule_python(schedule) + + assert_python_output_rebuilds_schedule(output, schedule) + assert ( + "schedule.task_args.network_scan.flags = " + "tasks.TaskNetworkScan.Flags.LTE_CELLS | tasks.TaskNetworkScan.Flags.SKIP_LTE_IF_WIFI_GOOD" + ) in output + assert ( + "schedule.task_args.network_scan.wifi.flags = " + "tasks.TaskNetworkScan.WifiArgs.Flags.INCLUDE_DUPLICATES | tasks.TaskNetworkScan.WifiArgs.Flags.SCAN_ACTIVE" + ) in output From 397a8f0b54e625e5f203aaa2957827c5d6ba1021 Mon Sep 17 00:00:00 2001 From: Jordan Yates Date: Thu, 6 Aug 2026 13:41:20 +1000 Subject: [PATCH 3/3] scripts: encode_task_schedule_example: added Add an example script that demonstrates building a schedule blob in python. Signed-off-by: Jordan Yates --- scripts/encode_task_schedule_example.py | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 scripts/encode_task_schedule_example.py diff --git a/scripts/encode_task_schedule_example.py b/scripts/encode_task_schedule_example.py new file mode 100644 index 0000000..9568dea --- /dev/null +++ b/scripts/encode_task_schedule_example.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Example: build a task schedule in Python and print its encoded bytes.""" + +import argparse +import base64 + +from infuse_iot.generated import tasks +from infuse_iot.task_runner.schedule import TaskSchedule + + +def example_schedule() -> TaskSchedule: + schedule = TaskSchedule() + schedule.task_id = tasks.TaskGnss.ID + schedule.validity = TaskSchedule.Validity.ACTIVE + schedule.states_start.states[0] = 9 + schedule.states_terminate.metadata = 1 + schedule.states_terminate.states[0] = 9 + schedule.task_logging[0].loggers = tasks.TdfDataLogger.BT_PERIPH + schedule.task_logging[0].tdf_mask = tasks.TaskGnss.Logging.PVT + schedule.task_args.gnss.constellations = ( + tasks.TaskGnss.Constellations.GPS | tasks.TaskGnss.Constellations.GALILEO | tasks.TaskGnss.Constellations.QZSS + ) + schedule.task_args.gnss.accuracy_m = 100 + schedule.task_args.gnss.position_dop = 250 + schedule.task_args.gnss.dynamic_model = 4 + + return schedule + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--encoding", + choices=("hex", "base64"), + default="hex", + help="output encoding", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + payload = bytes(example_schedule()) + + if args.encoding == "base64": + print(base64.b64encode(payload).decode("ascii")) + else: + print(payload.hex()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())