diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bd7fbd..b261329 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,14 +2,54 @@ name: CI on: push: - branches: [ main, master ] + branches: ['**'] tags: ['v*'] pull_request: workflow_dispatch: jobs: - build-examples: + platformio-v2-api: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + board: [esp32dev, esp32-s3-devkitc-1, esp32-c3-devkitm-1, esp32-p4-evboard] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Cache PlatformIO + uses: actions/cache@v4 + with: + path: ~/.platformio + key: ${{ runner.os }}-platformio-${{ hashFiles('**/library.json') }} + restore-keys: | + ${{ runner.os }}-platformio- + + - name: Install PIOArduino Core + run: python -m pip install --upgrade https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.18.zip + + - name: Install PIOArduino ESP32 Platform + run: pio platform install https://github.com/pioarduino/platform-espressif32.git + + - name: Build v2 API compile sketch + run: | + pio ci examples/v2_api_compile \ + --board ${{ matrix.board }} \ + --lib="." \ + --project-option "platform=https://github.com/pioarduino/platform-espressif32.git" \ + --project-option "build_unflags=-std=gnu++11" \ + --project-option "build_flags=-std=gnu++17" \ + --project-option "lib_deps=ArduinoJson@>=7.0.0, https://github.com/ESPToolKit/esp-date.git, https://github.com/ESPToolKit/esp-worker.git" + + platformio-examples: + runs-on: ubuntu-latest + needs: platformio-v2-api strategy: fail-fast: false matrix: @@ -53,9 +93,124 @@ jobs: fi done - arduino-cli: + platformio-device-tests: + runs-on: ubuntu-latest + needs: platformio-examples + strategy: + fail-fast: false + matrix: + board: [esp32dev, esp32-s3-devkitc-1, esp32-c3-devkitm-1, esp32-p4-evboard] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Cache PlatformIO + uses: actions/cache@v4 + with: + path: ~/.platformio + key: ${{ runner.os }}-platformio-${{ hashFiles('**/library.json') }} + restore-keys: | + ${{ runner.os }}-platformio- + + - name: Install PIOArduino Core + run: python -m pip install --upgrade https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.18.zip + + - name: Install PIOArduino ESP32 Platform + run: pio platform install https://github.com/pioarduino/platform-espressif32.git + + - name: Build device Unity test sketch + run: | + pio ci test/test_esp_scheduler \ + --board ${{ matrix.board }} \ + --lib="." \ + --project-option "platform=https://github.com/pioarduino/platform-espressif32.git" \ + --project-option "build_unflags=-std=gnu++11" \ + --project-option "build_flags=-std=gnu++17" \ + --project-option "lib_deps=ArduinoJson@>=7.0.0, https://github.com/ESPToolKit/esp-date.git, https://github.com/ESPToolKit/esp-worker.git" + + arduino-cli-v2-api: + runs-on: ubuntu-latest + needs: platformio-device-tests + env: + ESP32_CORE_VERSION: 3.3.3 + ESP32_PACKAGE_URL: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + ARDUINO_BOARDS: | + esp32:esp32:esp32 esp32dev + esp32:esp32:esp32s3 esp32-s3-devkitc-1 + esp32:esp32:esp32c3 esp32-c3-devkitm-1 + esp32:esp32:esp32p4 esp32-p4-evboard + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Arduino CLI + uses: arduino/setup-arduino-cli@v1 + + - name: Prepare Arduino directories + run: | + mkdir -p "${HOME}/.arduino15" + mkdir -p "${HOME}/Arduino/libraries" + + - name: Cache Arduino dependencies + uses: actions/cache@v4 + with: + path: | + ~/.arduino15 + ~/Arduino/libraries + key: ${{ runner.os }}-arduino-esp32-${{ env.ESP32_CORE_VERSION }}-${{ hashFiles('library.properties') }} + restore-keys: | + ${{ runner.os }}-arduino-esp32-${{ env.ESP32_CORE_VERSION }}- + ${{ runner.os }}-arduino-esp32- + + - name: Configure board manager for ESP32 + run: | + set -e + arduino-cli config init --overwrite + arduino-cli config set library.enable_unsafe_install true + arduino-cli config add board_manager.additional_urls "${ESP32_PACKAGE_URL}" + arduino-cli config dump + + - name: Install ESP32 core + run: | + arduino-cli core update-index --additional-urls "${ESP32_PACKAGE_URL}" + arduino-cli core install esp32:esp32@${ESP32_CORE_VERSION} --additional-urls "${ESP32_PACKAGE_URL}" + + - name: Install libraries + run: | + arduino-cli lib update-index + arduino-cli lib install "ArduinoJson" + arduino-cli lib install --git-url "https://github.com/ESPToolKit/esp-date.git" + arduino-cli lib install --git-url "https://github.com/ESPToolKit/esp-worker.git" + + - name: Add local library to sketchbook + run: | + set -e + SKETCHBOOK_DIR="${HOME}/Arduino" + mkdir -p "$SKETCHBOOK_DIR/libraries/ESPScheduler" + rsync -a --delete --exclude ".git" ./ "$SKETCHBOOK_DIR/libraries/ESPScheduler/" + + - name: Build v2 API compile sketch + env: + BOARDS: ${{ env.ARDUINO_BOARDS }} + run: | + set -euo pipefail + while read -r fqbn board_name; do + if [ -z "$fqbn" ]; then + continue + fi + echo "::group::Compiling v2 API sketch for ${board_name} (${fqbn})" + arduino-cli compile --fqbn "$fqbn" examples/v2_api_compile + echo "::endgroup::" + done <<< "$BOARDS" + + arduino-cli-examples: runs-on: ubuntu-latest - needs: build-examples + needs: arduino-cli-v2-api env: ESP32_CORE_VERSION: 3.3.3 ESP32_PACKAGE_URL: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b15b4a0..fe16c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,45 @@ The format follows Keep a Changelog and the project adheres to Semantic Versioni ## [Unreleased] ### Added +- Compile-only `examples/v2_api_compile` sketch to exercise the native v2 API surface in CI. +- Lifecycle and overlap coverage in the Unity test sketch for skip, queue-one, allow-parallel, background no-`tick()`, explicit shutdown, and v1 compatibility cleanup behavior. +- Optional built-in `ESPWorker` async backend selection through `SchedulerConfig`. + +### Changed +- CI now runs on every branch push and is split into PlatformIO v2 API builds, PlatformIO example builds, PlatformIO device test sketch builds, Arduino CLI v2 API builds, and Arduino CLI example builds. +- Background command submission now treats a full control queue as `QueueFull` immediately instead of waiting for the control timeout window. +- `SchedulerCore` now uses a scheduler-owned job id index, explicit pending-schedule bookkeeping, direct-indexed completion events, and heap-top validation instead of recovering hot-path state through whole-container scans. + +### Fixed +- Scheduler reschedule paths now clear `hasNext` if a due-heap insertion fails, so jobs are retried on the next dispatch pass instead of getting stuck in a primed-but-undispatchable state. +- PSRAM task-stack flags are now honored by the scheduler service task, worker-pool executor, and dedicated-task executor when the platform supports external task stacks. +- Background next-deadline lookup now purges stale heap entries lazily and returns the actual earliest valid due job without scanning the heap. +- Completion events now validate slot index, job id, and generation directly, which keeps stale completions from touching reused slots. + +## [2.0.0] - 2026-03-27 +### Added +- New `ESPScheduler` v2 API with explicit `begin()` / `end()` lifecycle and `SchedulerResult` return types. +- `ScheduleSpec`/`ScheduleCalculator` split so schedule validation and next-occurrence math are independent from runtime state. +- `SchedulerCore` with min-heap due tracking, centralized job ownership, generation counters, and explicit overlap handling. +- `SchedulerMode::Manual` and `SchedulerMode::Background`. +- `DispatchPolicy` and `OverlapPolicy`. +- Built-in worker-pool executor, dedicated-task executor, and `ESPWorkerExecutorAdapter`. +- Background scheduler service with one scheduler task, command queue, and event queue. +- Job-id-based `getJobInfo()` with runtime state fields for debugging. +- New v2-native examples for manual mode, background worker pool, `ESPWorker` adapter, shutdown, and v1 compatibility. +- `ESPSchedulerV1Compat` wrapper for legacy code paths. + +### Changed +- Async scheduling no longer defaults to one FreeRTOS task per scheduled job. +- Background mode no longer requires `tick()`. +- Public docs now describe v2 as the primary API surface. + +### Fixed +- Scheduler ownership and shutdown paths are centralized instead of being spread across per-job worker tasks. +- Async completion now flows back through the scheduler core, keeping reschedule decisions in one place. + +## [1.0.2] - 2025-12-07 +### Added - Clock validity guard: inline and worker jobs stay idle until the wall clock reaches a configurable minimum (default 2020-01-01 UTC) to prevent catch-up storms when SNTP sets time after boot. - `std::function` callback overloads for `addJob`/`addJobOnceUtc` to allow capturing lambdas. - `std::function` overloads for `addJob`/`addJobOnceUtc` to allow no-arg lambdas. @@ -16,32 +55,5 @@ The format follows Keep a Changelog and the project adheres to Semantic Versioni - `isInitialized()` lifecycle state on `ESPScheduler`, including explicit teardown/re-init behavior after `deinit()`. - Lifecycle Unity tests for teardown safety (`deinit` before use, repeated `deinit`, and re-init by scheduling again). -### Fixed -- Worker job tasks no longer capture the scheduler instance pointer, avoiding use-after-free risks during scheduler teardown. -- Worker jobs now spawn directly via FreeRTOS (`xTaskCreatePinnedToCore`) using `SchedulerTaskConfig` values. -- Scheduler-owned inline/worker job container allocations and worker context allocations now follow the scheduler PSRAM buffer policy while keeping task-stack PSRAM handling (`usePsramStack`) separate. -- `deinit()` now releases scheduler-owned runtime buffers and is fully idempotent when called multiple times. - -## [1.0.1] - 2025-12-07 -### Added -- `JobInfo` inspector and `getJobInfo()` helper to enumerate active inline/worker jobs with their schedules and next run time. -- `cleanup()` helper to purge finished inline/worker jobs when not driving the scheduler via `tick()`. - -### Changed -- `Schedule::weeklyAtLocal()` now treats an empty weekday mask as “any day” instead of producing an empty field, and `ScheduleField::list()` documents that out-of-range values clear the field. -- Worker jobs now track `nextRunUtc` inside their context to align with the inspector API. - -## [1.0.0] - 2025-12-07 -### Added -- Initial ESPScheduler cron-style engine built atop ESPDate with inline and ESPWorker-backed task modes. -- Schedule helpers for daily, weekly (bitmask DOW), monthly, one-shot UTC triggers, and custom cron-like fields. -- Inline scheduler loop with `tick()` plus per-job pause/resume/cancel controls. -- Worker task mode that spawns a dedicated FreeRTOS task per job via ESPWorker with optional PSRAM stacks. -- Examples for inline and worker-driven jobs, README and metadata for Arduino/PlatformIO/ESP-IDF. -- CI + release workflows, issue/PR templates, and Unity smoke tests for cron matching. -- Expanded README with API map, cron recipes, and execution-mode guidance. -- Added focused examples covering inline one-shot, pause/resume, custom cron fields, monthly triggers, and worker-based weekly/one-shot jobs. - -[Unreleased]: https://github.com/ESPToolKit/esp-scheduler/compare/v1.0.1...HEAD -[1.0.1]: https://github.com/ESPToolKit/esp-scheduler/compare/v1.0.0...v1.0.1 -[1.0.0]: https://github.com/ESPToolKit/esp-scheduler/releases/tag/v1.0.0 +[Unreleased]: https://github.com/ESPToolKit/esp-scheduler/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/ESPToolKit/esp-scheduler/compare/v1.0.2...v2.0.0 diff --git a/README.md b/README.md index 3fe3063..da9c836 100644 --- a/README.md +++ b/README.md @@ -1,356 +1,221 @@ # ESPScheduler -ESPScheduler is a C++17, class-based scheduler for ESP32 firmware that brings cron-like calendar patterns without parsing cron strings. It builds on [ESPDate](https://github.com/ESPToolKit/esp-date) for all wall-clock math and can run jobs either inline (driven by `tick()`) or on dedicated native FreeRTOS tasks. +ESPScheduler v2 is a C++17 scheduler for ESP32 firmware that keeps the cron-style DSL from v1, but replaces the old task-per-job async model with one central scheduler core, one optional background service task, and pluggable executors. ## CI / Release / License [![CI](https://github.com/ESPToolKit/esp-scheduler/actions/workflows/ci.yml/badge.svg)](https://github.com/ESPToolKit/esp-scheduler/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/ESPToolKit/esp-scheduler?sort=semver)](https://github.com/ESPToolKit/esp-scheduler/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) +## What Changed In v2 +- One `SchedulerCore` owns job state, next-run computation, overlap handling, and rescheduling. +- Background mode uses one scheduler task and queue-based control-plane serialization. No mandatory `tick()` in background mode. +- Async execution goes through executors. The default path is a fixed worker pool instead of one FreeRTOS task per job. +- Manual mode still exists for tight firmware loops and uses the same core as background mode. +- The public API now uses `SchedulerResult` for mutating/query operations. +- A thin `ESPSchedulerV1Compat` wrapper is shipped for migration. + ## Features -- **Cron-style patterns, no strings**: express minute/hour/day/month/weekday filters with `ScheduleField` objects and helpers for daily/weekly/monthly runs. -- **Inline or worker execution**: run callbacks inside `tick()` or on their own FreeRTOS task (with separate PSRAM policies for buffers and task stacks). -- **One-shot UTC triggers**: schedule absolute UTC times alongside recurring patterns. -- **Astronomical schedules**: trigger jobs at sunrise/sunset (with minute offsets), moon phase angles/names, and moon illumination crossings. -- **Calendar-aware**: respects classic cron `dayOfMonth` vs `dayOfWeek` logic and always operates in local time. -- **Clock guard for unset RTC**: defaults to idling until the wall clock reaches 2020-01-01 UTC (configurable) so jobs do not replay from the 1970 epoch when SNTP syncs later. -- **Optional PSRAM buffer policy**: `ESPSchedulerConfig::usePSRAMBuffers` routes scheduler-owned job/context storage through ESPBufferManager with automatic fallback to default heap. -- **Class-based API**: everything hangs off an `ESPScheduler` instance; no global namespaces or macros. -- **Arduino / ESP-IDF friendly**: C++17, metadata for PlatformIO/Arduino CLI, and examples/tests ready for CI. - -## Getting Started -Install one of two ways: -- Download the repository zip from GitHub, extract it, and drop the folder into your PlatformIO `lib/` directory, Arduino IDE `libraries/` directory, or add it as an ESP-IDF component. -- Add the public GitHub URL to `lib_deps` in `platformio.ini` so PlatformIO fetches it for you: - ``` - lib_deps = https://github.com/ESPToolKit/esp-scheduler.git +- Cron-style schedule DSL with `ScheduleField`, `Schedule`, one-shot UTC helpers, sunrise/sunset helpers, and moon helpers. +- Manual mode and background mode under one API. +- `DispatchPolicy::Inline` or `DispatchPolicy::Async`. +- `OverlapPolicy::SkipIfRunning`, `QueueOne`, and `AllowParallel`. +- Built-in worker-pool executor, dedicated-task executor, and `ESPWorkerExecutorAdapter`. +- Optional built-in `ESPWorker` async backend via config when you want scheduler-managed integration without manual executor registration. +- Deterministic lifecycle with `begin()` / `end()`. +- Clock validity guard via `setMinValidUnixSeconds()` / `setMinValidUtc()`. +- `usePSRAMMetadata` routes scheduler-owned job metadata, slot storage, and due-heap buffers through the scheduler allocator. +- `usePsramStack` is honored for the scheduler service task, worker-pool workers, and dedicated-task jobs when the target supports external task stacks. +- Arduino / ESP-IDF friendly metadata handling, branch-push CI builds, and device tests. + +## Install +- PlatformIO: + ```ini + lib_deps = + https://github.com/ESPToolKit/esp-date.git + https://github.com/ESPToolKit/esp-worker.git + https://github.com/ESPToolKit/esp-scheduler.git ``` -- Arduino CLI: install the library and its deps, then compile any example sketch: +- Arduino CLI: ```bash + arduino-cli lib install "ArduinoJson" arduino-cli lib install --git-url https://github.com/ESPToolKit/esp-date.git + arduino-cli lib install --git-url https://github.com/ESPToolKit/esp-worker.git arduino-cli lib install --git-url https://github.com/ESPToolKit/esp-scheduler.git - arduino-cli compile --fqbn esp32:esp32:esp32 examples/inline_daily ``` -Then include the scheduler together with its dependencies: +## Quick Start +### Manual mode ```cpp #include #include #include ESPDate date; -ESPScheduler scheduler(date); -void morningBackup(void* userData) { - Serial.println("Running morning backup..."); -} - -void setup() { - Serial.begin(115200); - // Configure SNTP/time zone before scheduling so ESPDate reports valid local time. - // Optional: raise the minimum valid clock to block jobs until SNTP sets time. - scheduler.setMinValidUtc(date.fromUtc(2020, 1, 1, 0, 0, 0)); - - // Run every weekday at 07:30 (local time) on a dedicated worker task - uint8_t weekdaysMask = 0b0111110; // Mon..Fri - scheduler.addJob( - Schedule::weeklyAtLocal(weekdaysMask, 7, 30), - SchedulerJobMode::WorkerTask, - &morningBackup - ); -} - -void loop() { - scheduler.tick(); // still safe to call; worker jobs self-drive - delay(5000); -} -``` - -Call `deinit()` explicitly when you no longer need scheduled jobs (for example before deep sleep or component shutdown): - -```cpp -scheduler.deinit(); -if (!scheduler.isInitialized()) { - Serial.println("Scheduler stopped"); +SchedulerConfig schedulerConfig() { + SchedulerConfig config{}; + config.mode = SchedulerMode::Manual; + return config; } -``` - -## API quick map -- `SchedulerJobMode`: `Inline` (runs inside `tick()`) or `WorkerTask` (dedicated FreeRTOS task). -- `ESPSchedulerConfig`: scheduler-level memory policy (`usePSRAMBuffers`) for scheduler-owned dynamic buffers. -- `SchedulerTaskConfig`: optional worker task config (name, stack size, priority, core, PSRAM stack flag). -- `SchedulerCallback`: `using SchedulerCallback = void (*)(void* userData);` -- `SchedulerFunction`: `using SchedulerFunction = std::function;` (capturing lambdas supported). -- `SchedulerFunctionNoData`: `using SchedulerFunctionNoData = std::function;` (no-arg lambdas supported). -- `setMinValidUnixSeconds` / `setMinValidUtc`: block all inline/worker jobs until the wall clock reaches this point (default: 2020-01-01 UTC). -- `ScheduleField`: bitmask-backed allowed values for one cron field. Builders: `any()`, `only()`, `range()`, `every()`, `rangeEvery()`, `list()`. -- `Schedule`: one-shot (`onceUtc`) or cron-like via helpers: `dailyAtLocal`, `weeklyAtLocal`, `monthlyOnDayLocal`, `custom`. -- Astronomical helpers: `sunrise(offsetMin)`, `sunset(offsetMin)`, `moonPhase(name/tolerance)`, `moonPhaseAngle(angle/tolerance)`, `moonIlluminationPercent(percent/tolerance)`. -- `JobInfo` / `getJobInfo(index, info)`: inspect active jobs (inline first, then worker), including enabled state, schedule copy, and next run (if known). -- `cleanup()`: manually purge finished inline/worker jobs when you are not calling `tick()`. -- `deinit()`: cancels and destroys all active jobs; destructor calls it automatically. -- `isInitialized()`: reports whether the scheduler is currently active after construction/re-init and false after `deinit()`. - -```cpp -ESPSchedulerConfig schedCfg; -schedCfg.usePSRAMBuffers = true; // falls back safely when PSRAM is unavailable -ESPScheduler scheduler(date, schedCfg); -uint32_t id = scheduler.addJob( - Schedule::dailyAtLocal(7, 30), - SchedulerJobMode::Inline, - &myCallback, - nullptr -); - -scheduler.pauseJob(id); -scheduler.resumeJob(id); -scheduler.cancelJob(id); -``` - -Capturing lambda callbacks are supported via the `std::function` overload: - -```cpp -DateTime bootTargetUtc = date.addMinutes(date.now(), 2); -scheduler.addJobOnceUtc( - bootTargetUtc, - SchedulerJobMode::Inline, - [this](void* /*userData*/) { - doSomething(); - } -); -``` - -No-arg lambdas are also supported: - -```cpp -scheduler.addJobOnceUtc( - bootTargetUtc, - SchedulerJobMode::Inline, - [this]() { - doSomething(); - } -); -``` - -## Schedule recipes -```cpp -// One-shot absolute UTC -Schedule once = Schedule::onceUtc(date.fromUtc(2025, 1, 1, 12, 0)); - -// Daily 08:15 (local) -Schedule daily = Schedule::dailyAtLocal(8, 15); - -// Weekdays at 18:30 (bitmask: 0=Sun, 1=Mon...) -uint8_t weekdays = 0b0111110; // Mon..Fri -Schedule weekly = Schedule::weeklyAtLocal(weekdays, 18, 30); - -// Monthly on the 1st at 09:00 (clamps 29/30/31 to valid) -Schedule monthly = Schedule::monthlyOnDayLocal(1, 9, 0); - -// Custom cron-like: every 5 minutes between 9-17 on Mon/Wed/Fri -int days[] = {1, 3, 5}; -Schedule custom = Schedule::custom( - ScheduleField::every(5), // minute - ScheduleField::range(9, 17), // hour - ScheduleField::any(), // day of month - ScheduleField::any(), // month - ScheduleField::list(days, 3) // day of week -); - -// Astronomical schedules (requires ESPDate initialized with latitude/longitude + TZ) -Schedule sunriseNow = Schedule::sunrise(); // exactly sunrise -Schedule sunsetLate = Schedule::sunset(15); // sunset + 15 minutes -Schedule preDawn = Schedule::sunrise(-30); // sunrise - 30 minutes -Schedule lastQuarter = Schedule::moonPhase(MoonPhaseName::LastQuarter, 2); -Schedule phase270 = Schedule::moonPhaseAngle(270, 2); // explicit angle -Schedule illum75 = Schedule::moonIlluminationPercent(75.0, 0.5); // percent + tolerance -``` -### Execution modes -- **Inline**: call `tick()` periodically; callbacks run in the caller’s context. -- **WorkerTask**: each job gets its own FreeRTOS task that sleeps until due. Configure stacks/priority/affinity via `SchedulerTaskConfig`. -- **Memory policy split**: `ESPSchedulerConfig::usePSRAMBuffers` controls scheduler-owned dynamic buffer placement; `SchedulerTaskConfig::usePsramStack` controls worker task stack placement. -- Even if you only schedule `WorkerTask` jobs, call `tick()` or `cleanup()` occasionally so the scheduler can drop finished worker job metadata. +ESPScheduler scheduler(date, schedulerConfig()); -### Cron semantics -- Resolution: minutes (seconds always treated as zero). -- Local time matching via ESPDate; honour your TZ/DST setup before scheduling. -- `dayOfMonth` vs `dayOfWeek`: classic cron OR rule when both are restricted; either can satisfy the day check. -- Astronomical moon jobs trigger on crossing events (with tolerance), not exact floating-point equality checks. -- Clock validity guard: inline and worker paths stay idle while `now()` is before `setMinValidUnixSeconds()` (default 2020-01-01 UTC). Set it to `0` if you explicitly want to allow pre-2000 times. - -## Examples - -For sun/moon schedules, see `examples/inline_astronomical/inline_astronomical.ino`. - -### Inline daily tick (no worker needed) -```cpp -#include -#include -#include - -ESPDate date; -ESPScheduler scheduler(date); // inline jobs only - -static void waterPlants(void* /*userData*/) { - Serial.println("Watering plants..."); +static void pulse(void* /*userData*/) { + Serial.println("manual inline pulse"); } void setup() { Serial.begin(115200); - // Set TZ + SNTP before scheduling so local time is valid - scheduler.setMinValidUtc(date.fromUtc(2020, 1, 1, 0, 0, 0)); + scheduler.begin(); - // 07:00 every day, inline - scheduler.addJob( - Schedule::dailyAtLocal(7, 0), - SchedulerJobMode::Inline, - &waterPlants - ); + JobOptions options{}; + scheduler.addJob(Schedule::dailyAtLocal(8, 15), options, &pulse, nullptr); } void loop() { - scheduler.tick(); // computes next runs using date.now() + scheduler.tick(); delay(1000); } ``` -### Worker task with custom stack/priority +### Background mode with worker pool ```cpp #include #include #include ESPDate date; -ESPScheduler scheduler(date); +ESPScheduler scheduler(date); // background mode by default -static void backupJob(void* /*userData*/) { - Serial.println("Backing up to cloud..."); - // heavy work is safe here; job owns its own FreeRTOS task +static void syncJob(void* /*userData*/) { + Serial.println("background worker-pool job"); } void setup() { Serial.begin(115200); - scheduler.setMinValidUtc(date.fromUtc(2020, 1, 1, 0, 0, 0)); + scheduler.begin(); - SchedulerTaskConfig cfg; - cfg.name = "backup"; - cfg.stackSize = 8192; - cfg.priority = 3; - cfg.coreId = 1; - cfg.usePsramStack = true; + JobOptions options{}; + options.dispatch = DispatchPolicy::Async; scheduler.addJob( - Schedule::weeklyAtLocal(0b0000010, 2, 30), // Mondays 02:30 local - SchedulerJobMode::WorkerTask, - &backupJob, - nullptr, - &cfg + Schedule::weeklyAtLocal(0b0111110, 18, 30), + options, + &syncJob, + nullptr ); } void loop() { - scheduler.tick(); // still safe to call; frees finished worker metadata - delay(1000); + delay(2000); } ``` -### One-shot + inspecting/pause/resume +## Public API +- `bool begin()` / `void end(bool waitForRunningJobs = true, uint32_t timeoutMs = 5000)` +- `SchedulerResult addJob(...)` +- `SchedulerResult addJobOnceUtc(...)` +- `SchedulerResult cancelJob(...)`, `pauseJob(...)`, `resumeJob(...)`, `cancelAll()` +- `void tick()` / `tick(nowUtc)` for manual mode +- `SchedulerResult jobCount() const` +- `SchedulerResult getJobInfo(jobId, out) const` +- `SchedulerResult registerExecutor(ISchedulerExecutor*)` before `begin()` +- `uint8_t defaultWorkerExecutor() const` +- `uint8_t defaultESPWorkerExecutor() const` +- `uint8_t defaultDedicatedExecutor() const` + +### Scheduling types +- `ScheduleField`: `any()`, `only()`, `range()`, `every()`, `rangeEvery()`, `list()` +- `Schedule`: `onceUtc`, `dailyAtLocal`, `weeklyAtLocal`, `monthlyOnDayLocal`, `sunrise`, `sunset`, `moonPhase`, `moonPhaseAngle`, `moonIlluminationPercent`, `custom` + +### Dispatch and overlap ```cpp -#include -#include -#include +JobOptions options{}; +options.dispatch = DispatchPolicy::Async; +options.overlap = OverlapPolicy::SkipIfRunning; +options.executorId = scheduler.defaultWorkerExecutor(); +options.name = "db-sync"; +``` -ESPDate date; -ESPScheduler scheduler(date); +### Built-in ESPWorker opt-in +```cpp +ESPWorker worker; -static void firmwareSwap(void* /*userData*/) { - Serial.println("Swapping firmware banks now"); -} +SchedulerConfig config{}; +config.defaultAsyncBackend = AsyncExecutorBackend::ESPWorker; +config.espWorker = &worker; -void setup() { - Serial.begin(115200); - DateTime when = date.fromUtc(2025, 1, 15, 12, 0, 0); - uint32_t id = scheduler.addJobOnceUtc( - when, - SchedulerJobMode::Inline, - &firmwareSwap - ); +ESPScheduler scheduler(date, config); - JobInfo info{}; - if (scheduler.getJobInfo(0, info)) { - Serial.printf("Job %u next run: %lld\n", info.id, info.nextRunUtc.epochSeconds); - scheduler.pauseJob(info.id); // stop until resumeJob is called - scheduler.resumeJob(info.id); - } -} +JobOptions options{}; +options.dispatch = DispatchPolicy::Async; +options.executorId = scheduler.defaultESPWorkerExecutor(); +``` -void loop() { - scheduler.tick(); - delay(500); -} +### Dedicated-task opt-in +```cpp +DedicatedTaskOptions task{}; +task.name = "isolated-job"; +task.stackSize = 8192; +task.priority = 2; + +JobOptions options{}; +options.dispatch = DispatchPolicy::Async; +options.executorId = scheduler.defaultDedicatedExecutor(); +options.dedicatedTask = &task; ``` -Example sketches in this repo: -- `examples/inline_daily/inline_daily.ino` — inline daily tick loop. -- `examples/inline_one_shot/inline_one_shot.ino` — single UTC trigger inline. -- `examples/inline_pause_resume/inline_pause_resume.ino` — pausing/resuming a repeating inline job. -- `examples/inline_every_day_midnight/inline_every_day_midnight.ino` — every day at local midnight. -- `examples/inline_every_hour/inline_every_hour.ino` — every hour (top of hour) every day. -- `examples/inline_every_minute/inline_every_minute.ino` — every minute all day. -- `examples/inline_every_minute_selected_days/inline_every_minute_selected_days.ino` — every minute on selected weekdays. -- `examples/inline_every_hour_selected_days/inline_every_hour_selected_days.ino` — every hour on selected weekdays. -- `examples/inline_every_15_minutes_work_hours/inline_every_15_minutes_work_hours.ino` — every 15 minutes during business hours. -- `examples/worker_weekly/worker_weekly.ino` — weekly heavy job on its own task with custom stack/priority. -- `examples/worker_one_shot/worker_one_shot.ino` — one-shot worker task using PSRAM stack. -- `examples/custom_fields/custom_fields.ino` — custom cron fields (every N minutes, selected weekdays/hours). -- `examples/monthly_on_day/monthly_on_day.ino` — monthly day-of-month trigger with clamping. - -## Gotchas -- Always set time zone and SNTP before scheduling; pair that with `setMinValidUtc` so jobs do not all replay at boot from the 1970 epoch. -- Even when you only run worker tasks, call `tick()` or `cleanup()` periodically so finished worker metadata is freed. -- `ScheduleField::list` drops out-of-range values; if every entry is invalid, `addJob` returns `0` because the schedule fails validation. -- Matching happens at minute resolution; if you need per-second triggers, pair ESPScheduler with ESPTimer counters instead. - -## Restrictions -- Designed for ESP32 boards (Arduino-ESP32 or ESP-IDF) with FreeRTOS and C++17 enabled. -- Depends on ESPDate for wall-clock math. -- Each worker job spawns its own task with its own stack; size those stacks (or enable PSRAM stacks) according to your workload. -- Schedules operate in local time and clamp invalid calendar combinations (e.g., 31st on shorter months). - -## Examples (one focus per sketch) -- `examples/inline_daily/inline_daily.ino` — inline daily tick loop. -- `examples/inline_one_shot/inline_one_shot.ino` — single UTC trigger inline. -- `examples/inline_pause_resume/inline_pause_resume.ino` — pausing/resuming a repeating inline job. -- `examples/inline_every_day_midnight/inline_every_day_midnight.ino` — every day at local midnight. -- `examples/inline_every_hour/inline_every_hour.ino` — every hour (top of hour) every day. -- `examples/inline_every_minute/inline_every_minute.ino` — every minute all day. -- `examples/inline_every_minute_selected_days/inline_every_minute_selected_days.ino` — every minute on selected weekdays. -- `examples/inline_every_hour_selected_days/inline_every_hour_selected_days.ino` — every hour on selected weekdays. -- `examples/inline_every_15_minutes_work_hours/inline_every_15_minutes_work_hours.ino` — every 15 minutes during business hours. -- `examples/worker_weekly/worker_weekly.ino` — weekly heavy job on its own task with custom stack/priority. -- `examples/worker_one_shot/worker_one_shot.ino` — one-shot worker task using PSRAM stack. -- `examples/custom_fields/custom_fields.ino` — custom cron fields (every N minutes, selected weekdays/hours). -- `examples/monthly_on_day/monthly_on_day.ino` — monthly day-of-month trigger with clamping. - -## Tests -- Unity-based device tests live in `test/test_esp_scheduler`; drop the folder into a PlatformIO workspace and run `pio test -e esp32dev` against real hardware. -- Host-side CTest is intentionally skipped because the scheduler relies on ESP32 FreeRTOS and ESPDate wall-clock helpers. -- CI also compiles all examples through PlatformIO and Arduino CLI across ESP32, S3, C3, and P4 boards. - -## Formatting Baseline - -This repository follows the firmware formatting baseline from `esptoolkit-template`: -- `.clang-format` is the source of truth for C/C++/INO layout. -- `.editorconfig` enforces tabs (`tab_width = 4`), LF endings, and final newline. -- Format all tracked firmware sources with `bash scripts/format_cpp.sh`. +## Executor Model +- `InlineExecutor`: runs in scheduler context. +- `WorkerPoolExecutor`: default async executor for ESP32. +- `AsyncExecutorBackend::ESPWorker`: optional built-in async backend when configured with `SchedulerConfig::espWorker`. +- `ESPWorkerExecutorAdapter`: bridges to an existing `ESPWorker`. +- `DedicatedTaskExecutor`: advanced opt-in path, also used by the v1 compatibility wrapper for per-job task config. + +## Memory And Shutdown Notes +- Scheduler-owned runtime metadata now uses explicit non-throwing allocation paths and reports `SchedulerError::NoMemory` on API paths that can fail cleanly. +- Job lookup and completion bookkeeping are direct-indexed internally, and jobs waiting for schedule recomputation are tracked explicitly instead of being recovered by scanning the whole job table each wake cycle. +- Queue submission in background mode returns `QueueFull` if the command queue has no space; `Timeout` is reserved for commands that were accepted but not acknowledged within the control timeout. +- `end(false)` stops intake, detaches completion routing, and tears down scheduler-owned resources without waiting for async callbacks to finish posting back into the core. +- `end(true)` cancels pending work, drains active async completions, then stops executors and the background service. +- Worker-pool and service shutdown still use force-delete after timeout as a deliberate best-effort fallback for stuck tasks; that path is documented behavior, not graceful draining. + +## Time Semantics +- Recurring schedules are evaluated in local time. +- One-shot UTC schedules stay exact. +- `dayOfMonth` and `dayOfWeek` follow classic cron OR semantics. +- Moon helpers trigger on crossings with tolerance windows. +- The scheduler idles until `now >= minValidUnixSeconds`. + +## Examples +- `examples/v2_manual_inline` +- `examples/v2_background_worker_pool` +- `examples/v2_espworker_adapter` +- `examples/v2_shutdown` +- `examples/v1_compat_wrapper` +- `examples/v2_api_compile` for CI coverage of the native v2 API surface + +The older v1-style sketches are still present and routed through `ESPSchedulerV1Compat`. That wrapper is migration support, not the primary API. + +## v1 Compatibility +`ESPSchedulerV1Compat` preserves the old shape: +- `SchedulerJobMode::Inline` maps to v2 inline dispatch. +- `SchedulerJobMode::WorkerTask` maps to async dispatch. +- Per-job `SchedulerTaskConfig` is routed through the dedicated-task executor. +- `deinit()`, `cleanup()`, and index-based `getJobInfo()` remain available on the compatibility wrapper. + +## Testing +- Device Unity tests live under `test/test_esp_scheduler`. +- CI runs on pushes, pull requests, and workflow dispatch. +- PlatformIO CI is split into v2 API compile coverage, example builds, and device test sketch builds. +- Arduino CLI CI separately compiles the v2 API sketch and the example set. ## License -MIT — see `LICENSE.md`. +ESPScheduler is released under the [MIT License](LICENSE.md). ## ESPToolKit -- Check out other libraries: https://github.com/orgs/ESPToolKit/repositories -- Hang out on Discord: https://discord.gg/WG8sSqAy -- Support the project: https://ko-fi.com/esptoolkit -- Visit the website: https://www.esptoolkit.hu/ +- Website: +- GitHub: +- Support: diff --git a/examples/custom_fields/custom_fields.ino b/examples/custom_fields/custom_fields.ino index 67c1093..cd4d337 100644 --- a/examples/custom_fields/custom_fields.ino +++ b/examples/custom_fields/custom_fields.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void customInline(void *userData) { const char *label = static_cast(userData); diff --git a/examples/inline_astronomical/inline_astronomical.ino b/examples/inline_astronomical/inline_astronomical.ino index 5313c1f..232bdd6 100644 --- a/examples/inline_astronomical/inline_astronomical.ino +++ b/examples/inline_astronomical/inline_astronomical.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void astroCallback(void *userData) { const char *label = static_cast(userData); diff --git a/examples/inline_daily/inline_daily.ino b/examples/inline_daily/inline_daily.ino index e37ff6d..f4cbc7a 100644 --- a/examples/inline_daily/inline_daily.ino +++ b/examples/inline_daily/inline_daily.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void dailyInline(void *userData) { (void)userData; diff --git a/examples/inline_every_15_minutes_work_hours/inline_every_15_minutes_work_hours.ino b/examples/inline_every_15_minutes_work_hours/inline_every_15_minutes_work_hours.ino index a12dd87..ecb403a 100644 --- a/examples/inline_every_15_minutes_work_hours/inline_every_15_minutes_work_hours.ino +++ b/examples/inline_every_15_minutes_work_hours/inline_every_15_minutes_work_hours.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void workHoursPulse(void *userData) { (void)userData; diff --git a/examples/inline_every_day_midnight/inline_every_day_midnight.ino b/examples/inline_every_day_midnight/inline_every_day_midnight.ino index 4bffd96..26c9c93 100644 --- a/examples/inline_every_day_midnight/inline_every_day_midnight.ino +++ b/examples/inline_every_day_midnight/inline_every_day_midnight.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void everyDay(void *userData) { (void)userData; diff --git a/examples/inline_every_hour/inline_every_hour.ino b/examples/inline_every_hour/inline_every_hour.ino index 88dd75d..24dde24 100644 --- a/examples/inline_every_hour/inline_every_hour.ino +++ b/examples/inline_every_hour/inline_every_hour.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void everyHour(void *userData) { (void)userData; diff --git a/examples/inline_every_hour_selected_days/inline_every_hour_selected_days.ino b/examples/inline_every_hour_selected_days/inline_every_hour_selected_days.ino index c502e2b..3f5c5c1 100644 --- a/examples/inline_every_hour_selected_days/inline_every_hour_selected_days.ino +++ b/examples/inline_every_hour_selected_days/inline_every_hour_selected_days.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void hourOnSelectedDays(void *userData) { (void)userData; diff --git a/examples/inline_every_minute/inline_every_minute.ino b/examples/inline_every_minute/inline_every_minute.ino index f7e4316..2a312dd 100644 --- a/examples/inline_every_minute/inline_every_minute.ino +++ b/examples/inline_every_minute/inline_every_minute.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void everyMinute(void *userData) { (void)userData; diff --git a/examples/inline_every_minute_selected_days/inline_every_minute_selected_days.ino b/examples/inline_every_minute_selected_days/inline_every_minute_selected_days.ino index 02d530b..5327eea 100644 --- a/examples/inline_every_minute_selected_days/inline_every_minute_selected_days.ino +++ b/examples/inline_every_minute_selected_days/inline_every_minute_selected_days.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void minuteOnSelectedDays(void *userData) { (void)userData; diff --git a/examples/inline_one_shot/inline_one_shot.ino b/examples/inline_one_shot/inline_one_shot.ino index ddb1bfa..abf9a90 100644 --- a/examples/inline_one_shot/inline_one_shot.ino +++ b/examples/inline_one_shot/inline_one_shot.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); bool done = false; void onceInline(void *userData) { diff --git a/examples/inline_pause_resume/inline_pause_resume.ino b/examples/inline_pause_resume/inline_pause_resume.ino index a8d00d6..a1ea13d 100644 --- a/examples/inline_pause_resume/inline_pause_resume.ino +++ b/examples/inline_pause_resume/inline_pause_resume.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); uint32_t jobId = 0; void recurringInline(void *userData) { diff --git a/examples/monthly_on_day/monthly_on_day.ino b/examples/monthly_on_day/monthly_on_day.ino index c68af78..34e2224 100644 --- a/examples/monthly_on_day/monthly_on_day.ino +++ b/examples/monthly_on_day/monthly_on_day.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void monthlyInline(void *userData) { (void)userData; diff --git a/examples/v1_compat_wrapper/v1_compat_wrapper.ino b/examples/v1_compat_wrapper/v1_compat_wrapper.ino new file mode 100644 index 0000000..b710c25 --- /dev/null +++ b/examples/v1_compat_wrapper/v1_compat_wrapper.ino @@ -0,0 +1,22 @@ +#include +#include +#include + +ESPDate date; +ESPSchedulerV1Compat scheduler(date); + +static void compatJob(void *userData) { + (void)userData; + Serial.println("[scheduler-v1-compat] callback"); +} + +void setup() { + Serial.begin(115200); + delay(200); + scheduler.addJob(Schedule::dailyAtLocal(8, 0), SchedulerJobMode::Inline, &compatJob, nullptr); +} + +void loop() { + scheduler.tick(); + delay(1000); +} diff --git a/examples/v2_api_compile/v2_api_compile.ino b/examples/v2_api_compile/v2_api_compile.ino new file mode 100644 index 0000000..e381850 --- /dev/null +++ b/examples/v2_api_compile/v2_api_compile.ino @@ -0,0 +1,120 @@ +#include +#include +#include +#include + +namespace { +class CompileOnlyExecutor : public ISchedulerExecutor { + public: + bool begin(const std::shared_ptr &runtime) override { + runtime_ = runtime; + return true; + } + + void end(bool drainRunningJobs) override { + (void)drainRunningJobs; + runtime_.reset(); + } + + bool submit(const JobInvocation &invocation) override { + (void)invocation; + return true; + } + + const char *name() const override { + return "compile-only"; + } + + private: + std::shared_ptr runtime_{}; +}; + +ESPDate date; +ESPWorker worker; +CompileOnlyExecutor extraExecutor; + +void rawCallback(void *userData) { + (void)userData; +} + +SchedulerConfig makeSchedulerConfig() { + SchedulerConfig config{}; + config.mode = SchedulerMode::Manual; + config.usePSRAMMetadata = true; + config.defaultAsyncBackend = AsyncExecutorBackend::ESPWorker; + config.espWorker = &worker; + config.service.usePsramStack = false; + config.defaultWorkerPool.usePsramStack = false; + config.defaultDedicatedTask.usePsramStack = false; + return config; +} +} // namespace + +ESPScheduler scheduler(date, makeSchedulerConfig()); + +void setup() { + ESPDateConfig dateConfig{}; + dateConfig.timeZone = "UTC0"; + date.init(dateConfig); + + ESPWorker::Config workerConfig{}; + worker.init(workerConfig); + + SchedulerResult compileExecutorId = scheduler.registerExecutor(&extraExecutor); + (void)compileExecutorId; + + scheduler.begin(); + scheduler.setMinValidUnixSeconds(0); + + JobOptions inlineOptions{}; + inlineOptions.name = "inline"; + (void)scheduler.addJob(Schedule::dailyAtLocal(8, 0), inlineOptions, &rawCallback, nullptr); + + JobOptions asyncOptions{}; + asyncOptions.dispatch = DispatchPolicy::Async; + asyncOptions.executorId = scheduler.defaultESPWorkerExecutor(); + asyncOptions.overlap = OverlapPolicy::QueueOne; + asyncOptions.name = "esp-worker"; + (void)scheduler.addJobOnceUtc( + date.fromUtc(2026, 1, 1, 12, 0, 0), + asyncOptions, + SchedulerFunction([](void *) {}) + ); + + DedicatedTaskOptions dedicated{}; + dedicated.name = "compile-task"; + dedicated.stackSize = 8192; + dedicated.priority = 2; + + JobOptions dedicatedOptions{}; + dedicatedOptions.dispatch = DispatchPolicy::Async; + dedicatedOptions.executorId = scheduler.defaultDedicatedExecutor(); + dedicatedOptions.dedicatedTask = &dedicated; + (void)scheduler.addJob(Schedule::weeklyAtLocal(0b0111110, 18, 30), dedicatedOptions, []() {}); + + JobOptions customExecutorOptions{}; + customExecutorOptions.dispatch = DispatchPolicy::Async; + customExecutorOptions.executorId = compileExecutorId.ok() ? compileExecutorId.value + : scheduler.defaultESPWorkerExecutor(); + (void)scheduler.addJob( + Schedule::custom( + ScheduleField::only(0), + ScheduleField::only(9), + ScheduleField::any(), + ScheduleField::any(), + ScheduleField::only(1) + ), + customExecutorOptions, + SchedulerFunction([](void *) {}) + ); + + JobInfo info{}; + (void)scheduler.jobCount(); + (void)scheduler.getJobInfo(1, info); + scheduler.cancelAll(); + scheduler.end(true); + worker.deinit(); +} + +void loop() { +} diff --git a/examples/v2_background_worker_pool/v2_background_worker_pool.ino b/examples/v2_background_worker_pool/v2_background_worker_pool.ino new file mode 100644 index 0000000..5c70432 --- /dev/null +++ b/examples/v2_background_worker_pool/v2_background_worker_pool.ino @@ -0,0 +1,33 @@ +#include +#include +#include + +ESPDate date; +ESPScheduler scheduler(date); + +static void backgroundJob(void *userData) { + (void)userData; + Serial.println("[scheduler-v2] worker-pool callback start"); + delay(250); + Serial.println("[scheduler-v2] worker-pool callback done"); +} + +void setup() { + Serial.begin(115200); + delay(200); + + scheduler.begin(); + + JobOptions options{}; + options.dispatch = DispatchPolicy::Async; + scheduler.addJob( + Schedule::weeklyAtLocal(0b0111110, 18, 30), + options, + &backgroundJob, + nullptr + ); +} + +void loop() { + delay(2000); +} diff --git a/examples/v2_espworker_adapter/v2_espworker_adapter.ino b/examples/v2_espworker_adapter/v2_espworker_adapter.ino new file mode 100644 index 0000000..93041da --- /dev/null +++ b/examples/v2_espworker_adapter/v2_espworker_adapter.ino @@ -0,0 +1,40 @@ +#include +#include +#include +#include + +ESPDate date; +ESPWorker worker; +ESPScheduler scheduler(date); +ESPWorkerExecutorAdapter adapter(worker); + +static void adapterJob(void *userData) { + (void)userData; + Serial.println("[scheduler-v2] ESPWorker adapter callback"); +} + +void setup() { + Serial.begin(115200); + delay(200); + + worker.init({}); + SchedulerResult adapterId = scheduler.registerExecutor(&adapter); + if (!adapterId.ok()) { + Serial.println("failed to register adapter"); + return; + } + + if (!scheduler.begin()) { + Serial.println("scheduler begin failed"); + return; + } + + JobOptions options{}; + options.dispatch = DispatchPolicy::Async; + options.executorId = adapterId.value; + scheduler.addJob(Schedule::dailyAtLocal(7, 30), options, &adapterJob, nullptr); +} + +void loop() { + delay(2000); +} diff --git a/examples/v2_manual_inline/v2_manual_inline.ino b/examples/v2_manual_inline/v2_manual_inline.ino new file mode 100644 index 0000000..57b428a --- /dev/null +++ b/examples/v2_manual_inline/v2_manual_inline.ino @@ -0,0 +1,32 @@ +#include +#include +#include + +ESPDate date; + +SchedulerConfig schedulerConfig() { + SchedulerConfig config{}; + config.mode = SchedulerMode::Manual; + return config; +} + +ESPScheduler scheduler(date, schedulerConfig()); + +static void manualJob(void *userData) { + (void)userData; + Serial.println("[scheduler-v2] manual inline callback"); +} + +void setup() { + Serial.begin(115200); + delay(200); + + scheduler.begin(); + JobOptions options{}; + scheduler.addJob(Schedule::dailyAtLocal(8, 15), options, &manualJob, nullptr); +} + +void loop() { + scheduler.tick(); + delay(1000); +} diff --git a/examples/v2_shutdown/v2_shutdown.ino b/examples/v2_shutdown/v2_shutdown.ino new file mode 100644 index 0000000..29cc7af --- /dev/null +++ b/examples/v2_shutdown/v2_shutdown.ino @@ -0,0 +1,33 @@ +#include +#include +#include + +ESPDate date; +ESPScheduler scheduler(date); + +static bool stopped = false; + +static void stopDemo(void *userData) { + (void)userData; + Serial.println("[scheduler-v2] one-shot fired"); +} + +void setup() { + Serial.begin(115200); + delay(200); + + scheduler.begin(); + + JobOptions options{}; + options.dispatch = DispatchPolicy::Async; + scheduler.addJobOnceUtc(date.addSeconds(date.now(), 5), options, &stopDemo, nullptr); +} + +void loop() { + if (!stopped && millis() > 10000) { + scheduler.end(true); + stopped = true; + Serial.println("[scheduler-v2] scheduler stopped cleanly"); + } + delay(100); +} diff --git a/examples/worker_one_shot/worker_one_shot.ino b/examples/worker_one_shot/worker_one_shot.ino index 28cb825..e82ad51 100644 --- a/examples/worker_one_shot/worker_one_shot.ino +++ b/examples/worker_one_shot/worker_one_shot.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void singleTask(void *userData) { (void)userData; diff --git a/examples/worker_weekly/worker_weekly.ino b/examples/worker_weekly/worker_weekly.ino index 960264c..cde475a 100644 --- a/examples/worker_weekly/worker_weekly.ino +++ b/examples/worker_weekly/worker_weekly.ino @@ -3,7 +3,7 @@ #include ESPDate date; -ESPScheduler scheduler(date); +ESPSchedulerV1Compat scheduler(date); void weeklyReport(void *userData) { (void)userData; diff --git a/library.json b/library.json index 75ca6d5..ef6b0ae 100644 --- a/library.json +++ b/library.json @@ -1,7 +1,7 @@ { "name": "ESPScheduler", - "version": "1.0.2", - "description": "Cron-style wall-clock scheduler for ESP32 firmware (Arduino or ESP-IDF)", + "version": "2.0.0", + "description": "Cron-style ESP32 scheduler with a central core, background service, and pluggable executors", "keywords": [ "esp32", "arduino", @@ -38,6 +38,10 @@ { "name": "ESPBufferManager", "version": "https://github.com/ESPToolKit/esp-buffer-manager.git" + }, + { + "name": "ESPWorker", + "version": "https://github.com/ESPToolKit/esp-worker.git" } ], "headers": [ diff --git a/library.properties b/library.properties index 90e8c80..fd95474 100644 --- a/library.properties +++ b/library.properties @@ -1,12 +1,12 @@ name=ESPScheduler -version=1.0.2 +version=2.0.0 author=zekageri maintainer=zekageri -sentence=Cron-style wall-clock scheduler for ESP32 using ESPDate and FreeRTOS tasks. -paragraph=Schedule per-minute callbacks inline via tick() or on dedicated FreeRTOS tasks with cron-like patterns and one-shot UTC triggers. +sentence=Cron-style ESP32 scheduler with manual/background modes and pluggable executors. +paragraph=Schedule per-minute callbacks with a central scheduler core, background service task, worker-pool async execution, and a v1 compatibility wrapper. category=Timing url=https://github.com/ESPToolKit/esp-scheduler repository=https://github.com/ESPToolKit/esp-scheduler.git architectures=esp32 -depends=ArduinoJson,ESPDate,ESPBufferManager +depends=ArduinoJson,ESPDate,ESPBufferManager,ESPWorker license=MIT diff --git a/src/ESPScheduler.h b/src/ESPScheduler.h index 1ef7169..f79d5c8 100644 --- a/src/ESPScheduler.h +++ b/src/ESPScheduler.h @@ -1,3 +1,4 @@ #pragma once #include "esp_scheduler/scheduler.h" +#include "esp_scheduler/compat/v1_compat.h" diff --git a/src/esp_scheduler/compat/v1_compat.cpp b/src/esp_scheduler/compat/v1_compat.cpp new file mode 100644 index 0000000..5fbf93f --- /dev/null +++ b/src/esp_scheduler/compat/v1_compat.cpp @@ -0,0 +1,245 @@ +#include "v1_compat.h" + +#include + +namespace { +SchedulerConfig makeCompatSchedulerConfig(const ESPSchedulerConfig &config) { + SchedulerConfig v2Config{}; + v2Config.usePSRAMMetadata = config.usePSRAMBuffers; + return v2Config; +} + +DedicatedTaskOptions makeDedicatedOptions(const SchedulerTaskConfig *taskCfg) { + DedicatedTaskOptions options{}; + if (!taskCfg) { + return options; + } + options.name = taskCfg->name; + options.stackSize = taskCfg->stackSize; + options.priority = taskCfg->priority; + options.coreId = taskCfg->coreId; + options.usePsramStack = taskCfg->usePsramStack; + return options; +} +} // namespace + +ESPSchedulerV1Compat::ESPSchedulerV1Compat(ESPDate &date, ESPWorker *worker) + : ESPSchedulerV1Compat(date, worker, ESPSchedulerConfig{}) { +} + +ESPSchedulerV1Compat::ESPSchedulerV1Compat(ESPDate &date, const ESPSchedulerConfig &config) + : ESPSchedulerV1Compat(date, nullptr, config) { +} + +ESPSchedulerV1Compat::ESPSchedulerV1Compat( + ESPDate &date, ESPWorker *worker, const ESPSchedulerConfig &config +) + : scheduler_(date, makeCompatSchedulerConfig(config)) { + if (worker) { + workerAdapter_.reset(new ESPWorkerExecutorAdapter(*worker)); + if (workerAdapter_) { + SchedulerResult registered = scheduler_.registerExecutor(workerAdapter_.get()); + if (registered.ok()) { + workerExecutorId_ = registered.value; + } + } + } + scheduler_.begin(); +} + +void ESPSchedulerV1Compat::deinit() { + scheduler_.end(true); + trackedJobIds_.clear(); +} + +bool ESPSchedulerV1Compat::isInitialized() const { + return scheduler_.running(); +} + +void ESPSchedulerV1Compat::setMinValidUnixSeconds(int64_t minEpochSeconds) { + scheduler_.setMinValidUnixSeconds(minEpochSeconds); +} + +void ESPSchedulerV1Compat::setMinValidUtc(const DateTime &minUtc) { + scheduler_.setMinValidUtc(minUtc); +} + +int64_t ESPSchedulerV1Compat::minValidUnixSeconds() const { + return scheduler_.minValidUnixSeconds(); +} + +JobOptions ESPSchedulerV1Compat::jobOptionsForMode( + SchedulerJobMode mode, const SchedulerTaskConfig *taskCfg +) const { + JobOptions options{}; + if (mode == SchedulerJobMode::Inline) { + options.dispatch = DispatchPolicy::Inline; + return options; + } + + options.dispatch = DispatchPolicy::Async; + if (taskCfg) { + dedicatedTaskScratch_ = makeDedicatedOptions(taskCfg); + options.executorId = scheduler_.defaultDedicatedExecutor(); + options.dedicatedTask = &dedicatedTaskScratch_; + } else if (workerExecutorId_ >= 0) { + options.executorId = static_cast(workerExecutorId_); + } else { + options.executorId = scheduler_.defaultWorkerExecutor(); + } + return options; +} + +void ESPSchedulerV1Compat::trackJob(uint32_t jobId) { + if (jobId != 0) { + trackedJobIds_.push_back(jobId); + } +} + +void ESPSchedulerV1Compat::pruneTrackedJobs() const { + std::vector active{}; + for (uint32_t jobId : trackedJobIds_) { + JobInfo info{}; + if (scheduler_.getJobInfo(jobId, info).ok()) { + active.push_back(jobId); + } + } + trackedJobIds_ = active; +} + +uint32_t ESPSchedulerV1Compat::addJobOnceUtc( + const DateTime &whenUtc, + SchedulerJobMode mode, + SchedulerCallbackFn callback, + void *userData, + const SchedulerTaskConfig *taskCfg +) { + JobOptions options = jobOptionsForMode(mode, taskCfg); + SchedulerResult result = scheduler_.addJobOnceUtc(whenUtc, options, callback, userData); + trackJob(result.ok() ? result.value : 0); + return result.ok() ? result.value : 0; +} + +uint32_t ESPSchedulerV1Compat::addJobOnceUtc( + const DateTime &whenUtc, + SchedulerJobMode mode, + SchedulerFunction callback, + void *userData, + const SchedulerTaskConfig *taskCfg +) { + JobOptions options = jobOptionsForMode(mode, taskCfg); + SchedulerResult result = + scheduler_.addJobOnceUtc(whenUtc, options, std::move(callback), userData); + trackJob(result.ok() ? result.value : 0); + return result.ok() ? result.value : 0; +} + +uint32_t ESPSchedulerV1Compat::addJobOnceUtc( + const DateTime &whenUtc, + SchedulerJobMode mode, + SchedulerFunctionNoData callback, + const SchedulerTaskConfig *taskCfg +) { + JobOptions options = jobOptionsForMode(mode, taskCfg); + SchedulerResult result = + scheduler_.addJobOnceUtc(whenUtc, options, std::move(callback)); + trackJob(result.ok() ? result.value : 0); + return result.ok() ? result.value : 0; +} + +uint32_t ESPSchedulerV1Compat::addJob( + const ScheduleSpec &schedule, + SchedulerJobMode mode, + SchedulerCallbackFn callback, + void *userData, + const SchedulerTaskConfig *taskCfg +) { + JobOptions options = jobOptionsForMode(mode, taskCfg); + SchedulerResult result = scheduler_.addJob(schedule, options, callback, userData); + trackJob(result.ok() ? result.value : 0); + return result.ok() ? result.value : 0; +} + +uint32_t ESPSchedulerV1Compat::addJob( + const ScheduleSpec &schedule, + SchedulerJobMode mode, + SchedulerFunction callback, + void *userData, + const SchedulerTaskConfig *taskCfg +) { + JobOptions options = jobOptionsForMode(mode, taskCfg); + SchedulerResult result = scheduler_.addJob(schedule, options, std::move(callback), userData); + trackJob(result.ok() ? result.value : 0); + return result.ok() ? result.value : 0; +} + +uint32_t ESPSchedulerV1Compat::addJob( + const ScheduleSpec &schedule, + SchedulerJobMode mode, + SchedulerFunctionNoData callback, + const SchedulerTaskConfig *taskCfg +) { + JobOptions options = jobOptionsForMode(mode, taskCfg); + SchedulerResult result = scheduler_.addJob(schedule, options, std::move(callback)); + trackJob(result.ok() ? result.value : 0); + return result.ok() ? result.value : 0; +} + +bool ESPSchedulerV1Compat::cancelJob(uint32_t jobId) { + return scheduler_.cancelJob(jobId).ok(); +} + +bool ESPSchedulerV1Compat::pauseJob(uint32_t jobId) { + return scheduler_.pauseJob(jobId).ok(); +} + +bool ESPSchedulerV1Compat::resumeJob(uint32_t jobId) { + return scheduler_.resumeJob(jobId).ok(); +} + +void ESPSchedulerV1Compat::cancelAll() { + scheduler_.cancelAll(); + trackedJobIds_.clear(); +} + +void ESPSchedulerV1Compat::tick() { + scheduler_.tick(); +} + +void ESPSchedulerV1Compat::tick(const DateTime &nowUtc) { + scheduler_.tick(nowUtc); +} + +void ESPSchedulerV1Compat::cleanup() { + pruneTrackedJobs(); +} + +bool ESPSchedulerV1Compat::computeNextOccurrence( + const ScheduleSpec &schedule, const DateTime &fromUtc, DateTime &outNextUtc +) const { + return scheduler_.computeNextOccurrence(schedule, fromUtc, outNextUtc); +} + +bool ESPSchedulerV1Compat::getJobInfo(size_t index, SchedulerV1JobInfo &out) const { + pruneTrackedJobs(); + size_t current = 0; + for (uint32_t jobId : trackedJobIds_) { + JobInfo info{}; + if (!scheduler_.getJobInfo(jobId, info).ok()) { + continue; + } + if (current++ != index) { + continue; + } + out = SchedulerV1JobInfo{}; + out.id = info.id; + out.enabled = !info.paused; + out.mode = info.dispatch == DispatchPolicy::Inline ? SchedulerJobMode::Inline + : SchedulerJobMode::WorkerTask; + out.schedule = info.schedule; + out.nextRunUtc = info.nextRunUtc; + return true; + } + out = SchedulerV1JobInfo{}; + return false; +} diff --git a/src/esp_scheduler/compat/v1_compat.h b/src/esp_scheduler/compat/v1_compat.h new file mode 100644 index 0000000..3fa41d7 --- /dev/null +++ b/src/esp_scheduler/compat/v1_compat.h @@ -0,0 +1,114 @@ +#pragma once + +#include + +#include "../scheduler.h" +#include "../executors/esp_worker_executor.h" + +class ESPWorker; + +enum class SchedulerJobMode : uint8_t { + Inline = 0, + WorkerTask, +}; + +struct SchedulerTaskConfig { + const char *name = "sched-job"; + uint32_t stackSize = 4096; + UBaseType_t priority = 1; + BaseType_t coreId = tskNO_AFFINITY; + bool usePsramStack = false; +}; + +struct ESPSchedulerConfig { + bool usePSRAMBuffers = false; +}; + +struct SchedulerV1JobInfo { + uint32_t id = 0; + bool enabled = false; + SchedulerJobMode mode = SchedulerJobMode::Inline; + ScheduleSpec schedule{}; + DateTime nextRunUtc{}; +}; + +class ESPSchedulerV1Compat { + public: + ESPSchedulerV1Compat(ESPDate &date, ESPWorker *worker = nullptr); + ESPSchedulerV1Compat(ESPDate &date, const ESPSchedulerConfig &config); + ESPSchedulerV1Compat(ESPDate &date, ESPWorker *worker, const ESPSchedulerConfig &config); + + void deinit(); + bool isInitialized() const; + + void setMinValidUnixSeconds(int64_t minEpochSeconds); + void setMinValidUtc(const DateTime &minUtc); + int64_t minValidUnixSeconds() const; + + uint32_t addJobOnceUtc( + const DateTime &whenUtc, + SchedulerJobMode mode, + SchedulerCallbackFn callback, + void *userData = nullptr, + const SchedulerTaskConfig *taskCfg = nullptr + ); + uint32_t addJobOnceUtc( + const DateTime &whenUtc, + SchedulerJobMode mode, + SchedulerFunction callback, + void *userData = nullptr, + const SchedulerTaskConfig *taskCfg = nullptr + ); + uint32_t addJobOnceUtc( + const DateTime &whenUtc, + SchedulerJobMode mode, + SchedulerFunctionNoData callback, + const SchedulerTaskConfig *taskCfg = nullptr + ); + + uint32_t addJob( + const ScheduleSpec &schedule, + SchedulerJobMode mode, + SchedulerCallbackFn callback, + void *userData = nullptr, + const SchedulerTaskConfig *taskCfg = nullptr + ); + uint32_t addJob( + const ScheduleSpec &schedule, + SchedulerJobMode mode, + SchedulerFunction callback, + void *userData = nullptr, + const SchedulerTaskConfig *taskCfg = nullptr + ); + uint32_t addJob( + const ScheduleSpec &schedule, + SchedulerJobMode mode, + SchedulerFunctionNoData callback, + const SchedulerTaskConfig *taskCfg = nullptr + ); + + bool cancelJob(uint32_t jobId); + bool pauseJob(uint32_t jobId); + bool resumeJob(uint32_t jobId); + void cancelAll(); + + void tick(); + void tick(const DateTime &nowUtc); + void cleanup(); + + bool computeNextOccurrence( + const ScheduleSpec &schedule, const DateTime &fromUtc, DateTime &outNextUtc + ) const; + bool getJobInfo(size_t index, SchedulerV1JobInfo &out) const; + + private: + JobOptions jobOptionsForMode(SchedulerJobMode mode, const SchedulerTaskConfig *taskCfg) const; + void trackJob(uint32_t jobId); + void pruneTrackedJobs() const; + + mutable std::vector trackedJobIds_{}; + mutable DedicatedTaskOptions dedicatedTaskScratch_{}; + std::unique_ptr workerAdapter_{}; + int workerExecutorId_ = -1; + ESPScheduler scheduler_; +}; diff --git a/src/esp_scheduler/core/due_heap.h b/src/esp_scheduler/core/due_heap.h new file mode 100644 index 0000000..a18c47b --- /dev/null +++ b/src/esp_scheduler/core/due_heap.h @@ -0,0 +1,95 @@ +#pragma once + +#include +#include + +#include "runtime_containers.h" + +struct DueHeapEntry { + int64_t nextEpoch = 0; + size_t slotIndex = 0; + uint32_t generation = 0; +}; + +class DueHeap { + public: + explicit DueHeap(bool usePSRAM = false) : entries_(usePSRAM) { + } + + bool push(const DueHeapEntry &entry) { + if (!entries_.pushBack(entry)) { + return false; + } + std::size_t index = entries_.size() - 1; + while (index > 0) { + const std::size_t parent = (index - 1) / 2; + if (!Compare{}(entries_[parent], entries_[index])) { + break; + } + DueHeapEntry tmp = entries_[parent]; + entries_[parent] = entries_[index]; + entries_[index] = tmp; + index = parent; + } + return true; + } + + bool empty() const { + return entries_.empty(); + } + + const DueHeapEntry &top() const { + return entries_[0]; + } + + DueHeapEntry pop() { + DueHeapEntry entry = entries_[0]; + if (entries_.size() == 1) { + entries_.popBack(); + return entry; + } + entries_[0] = entries_[entries_.size() - 1]; + entries_.popBack(); + std::size_t index = 0; + while (true) { + const std::size_t left = index * 2 + 1; + const std::size_t right = left + 1; + std::size_t candidate = index; + if (left < entries_.size() && Compare{}(entries_[candidate], entries_[left])) { + candidate = left; + } + if (right < entries_.size() && Compare{}(entries_[candidate], entries_[right])) { + candidate = right; + } + if (candidate == index) { + break; + } + DueHeapEntry tmp = entries_[index]; + entries_[index] = entries_[candidate]; + entries_[candidate] = tmp; + index = candidate; + } + return entry; + } + + void clear() { + entries_.clear(); + } + + std::size_t size() const { + return entries_.size(); + } + + const DueHeapEntry &at(std::size_t index) const { + return entries_[index]; + } + + private: + struct Compare { + bool operator()(const DueHeapEntry &lhs, const DueHeapEntry &rhs) const { + return lhs.nextEpoch > rhs.nextEpoch; + } + }; + + SchedulerArray entries_{}; +}; diff --git a/src/esp_scheduler/core/job_record.h b/src/esp_scheduler/core/job_record.h new file mode 100644 index 0000000..6556d16 --- /dev/null +++ b/src/esp_scheduler/core/job_record.h @@ -0,0 +1,32 @@ +#pragma once + +#include "../executors/scheduler_executor.h" +#include "runtime_containers.h" + +struct JobRecord { + explicit JobRecord(bool usePSRAMMetadata = false) : name(usePSRAMMetadata) { + } + + uint32_t id = 0; + uint32_t generation = 1; + bool occupied = false; + + ScheduleSpec schedule{}; + DispatchPolicy dispatch = DispatchPolicy::Inline; + OverlapPolicy overlap = OverlapPolicy::SkipIfRunning; + uint8_t executorId = 0; + + bool paused = false; + bool canceled = false; + bool queuedWhileRunning = false; + bool hasNext = false; + bool pendingSchedule = false; + uint16_t runningCount = 0; + + DateTime nextRunUtc{}; + DateTime scheduleFromUtc{}; + CallbackRef callback{}; + SchedulerOwnedString name{}; + DedicatedTaskOptions dedicatedTask{}; + bool hasDedicatedTaskOptions = false; +}; diff --git a/src/esp_scheduler/core/runtime_containers.h b/src/esp_scheduler/core/runtime_containers.h new file mode 100644 index 0000000..e49b77d --- /dev/null +++ b/src/esp_scheduler/core/runtime_containers.h @@ -0,0 +1,470 @@ +#pragma once + +#include +#include +#include +#include + +#include "../scheduler_allocator.h" + +template class SchedulerArray { + public: + explicit SchedulerArray(bool usePSRAM = false) : usePSRAM_(usePSRAM) { + } + + ~SchedulerArray() { + clear(); + schedulerDeallocate(data_); + } + + SchedulerArray(const SchedulerArray &) = delete; + SchedulerArray &operator=(const SchedulerArray &) = delete; + + SchedulerArray(SchedulerArray &&other) noexcept + : data_(other.data_), + size_(other.size_), + capacity_(other.capacity_), + usePSRAM_(other.usePSRAM_) { + other.data_ = nullptr; + other.size_ = 0; + other.capacity_ = 0; + } + + SchedulerArray &operator=(SchedulerArray &&other) noexcept { + if (this == &other) { + return *this; + } + clear(); + schedulerDeallocate(data_); + data_ = other.data_; + size_ = other.size_; + capacity_ = other.capacity_; + usePSRAM_ = other.usePSRAM_; + other.data_ = nullptr; + other.size_ = 0; + other.capacity_ = 0; + return *this; + } + + bool swapRemove(std::size_t index) { + if (index >= size_) { + return false; + } + if (index + 1 != size_) { + data_[index].~T(); + new (&data_[index]) T(std::move(data_[size_ - 1])); + } + popBack(); + return true; + } + + bool reserve(std::size_t requested) { + if (requested <= capacity_) { + return true; + } + T *next = schedulerAllocate(requested, usePSRAM_); + if (!next) { + return false; + } + for (std::size_t index = 0; index < size_; ++index) { + new (&next[index]) T(std::move(data_[index])); + data_[index].~T(); + } + schedulerDeallocate(data_); + data_ = next; + capacity_ = requested; + return true; + } + + template bool emplaceBack(Args &&...args) { + if (size_ == capacity_) { + const std::size_t nextCapacity = capacity_ == 0 ? 4 : capacity_ * 2; + if (!reserve(nextCapacity)) { + return false; + } + } + new (&data_[size_]) T(std::forward(args)...); + ++size_; + return true; + } + + bool pushBack(const T &value) { + return emplaceBack(value); + } + + bool pushBack(T &&value) { + return emplaceBack(std::move(value)); + } + + void popBack() { + if (size_ == 0) { + return; + } + --size_; + data_[size_].~T(); + } + + void clear() { + for (std::size_t index = 0; index < size_; ++index) { + data_[index].~T(); + } + size_ = 0; + } + + T &operator[](std::size_t index) { + return data_[index]; + } + + const T &operator[](std::size_t index) const { + return data_[index]; + } + + T *begin() { + return data_; + } + + const T *begin() const { + return data_; + } + + T *end() { + return data_ + size_; + } + + const T *end() const { + return data_ + size_; + } + + std::size_t size() const { + return size_; + } + + bool empty() const { + return size_ == 0; + } + + void erase(std::size_t index) { + if (index >= size_) { + return; + } + data_[index].~T(); + for (std::size_t cursor = index; cursor + 1 < size_; ++cursor) { + new (&data_[cursor]) T(std::move(data_[cursor + 1])); + data_[cursor + 1].~T(); + } + --size_; + } + + bool usePSRAM() const { + return usePSRAM_; + } + + private: + T *data_ = nullptr; + std::size_t size_ = 0; + std::size_t capacity_ = 0; + bool usePSRAM_ = false; +}; + +class SchedulerOwnedString { + public: + explicit SchedulerOwnedString(bool usePSRAM = false) : usePSRAM_(usePSRAM) { + } + + ~SchedulerOwnedString() { + schedulerDeallocate(data_); + } + + SchedulerOwnedString(const SchedulerOwnedString &) = delete; + SchedulerOwnedString &operator=(const SchedulerOwnedString &) = delete; + + SchedulerOwnedString(SchedulerOwnedString &&other) noexcept + : data_(other.data_), length_(other.length_), usePSRAM_(other.usePSRAM_) { + other.data_ = nullptr; + other.length_ = 0; + } + + SchedulerOwnedString &operator=(SchedulerOwnedString &&other) noexcept { + if (this == &other) { + return *this; + } + schedulerDeallocate(data_); + data_ = other.data_; + length_ = other.length_; + usePSRAM_ = other.usePSRAM_; + other.data_ = nullptr; + other.length_ = 0; + return *this; + } + + bool assign(const char *text) { + schedulerDeallocate(data_); + data_ = nullptr; + length_ = 0; + if (!text || text[0] == '\0') { + return true; + } + while (text[length_] != '\0') { + ++length_; + } + data_ = schedulerAllocate(length_ + 1, usePSRAM_); + if (!data_) { + length_ = 0; + return false; + } + for (std::size_t index = 0; index < length_; ++index) { + data_[index] = text[index]; + } + data_[length_] = '\0'; + return true; + } + + void clear() { + schedulerDeallocate(data_); + data_ = nullptr; + length_ = 0; + } + + const char *c_str() const { + return data_; + } + + bool empty() const { + return data_ == nullptr || length_ == 0; + } + + private: + char *data_ = nullptr; + std::size_t length_ = 0; + bool usePSRAM_ = false; +}; + +class SchedulerIdIndex { + public: + explicit SchedulerIdIndex(bool usePSRAM = false) : usePSRAM_(usePSRAM) { + } + + bool set(uint32_t jobId, std::size_t slotIndex) { + if (jobId == 0) { + return false; + } + if (!ensureCapacityForInsert()) { + return false; + } + return insertOrAssign(jobId, slotIndex); + } + + bool get(uint32_t jobId, std::size_t &outSlotIndex) const { + if (!entries_ || capacity_ == 0 || jobId == 0) { + return false; + } + const std::size_t mask = capacity_ - 1; + std::size_t index = hash(jobId) & mask; + for (std::size_t probe = 0; probe < capacity_; ++probe) { + const Entry &entry = entries_[index]; + if (entry.state == EntryState::Empty) { + return false; + } + if (entry.state == EntryState::Occupied && entry.jobId == jobId) { + outSlotIndex = entry.slotIndex; + return true; + } + index = (index + 1) & mask; + } + return false; + } + + bool remove(uint32_t jobId) { + if (!entries_ || capacity_ == 0 || jobId == 0) { + return false; + } + const std::size_t mask = capacity_ - 1; + std::size_t index = hash(jobId) & mask; + for (std::size_t probe = 0; probe < capacity_; ++probe) { + Entry &entry = entries_[index]; + if (entry.state == EntryState::Empty) { + return false; + } + if (entry.state == EntryState::Occupied && entry.jobId == jobId) { + entry.state = EntryState::Deleted; + entry.jobId = 0; + entry.slotIndex = 0; + --size_; + ++deletedCount_; + return true; + } + index = (index + 1) & mask; + } + return false; + } + + void clear() { + if (!entries_) { + size_ = 0; + deletedCount_ = 0; + return; + } + for (std::size_t index = 0; index < capacity_; ++index) { + entries_[index].state = EntryState::Empty; + entries_[index].jobId = 0; + entries_[index].slotIndex = 0; + } + size_ = 0; + deletedCount_ = 0; + } + + ~SchedulerIdIndex() { + schedulerDeallocate(entries_); + } + + SchedulerIdIndex(const SchedulerIdIndex &) = delete; + SchedulerIdIndex &operator=(const SchedulerIdIndex &) = delete; + + SchedulerIdIndex(SchedulerIdIndex &&other) noexcept + : entries_(other.entries_), + capacity_(other.capacity_), + size_(other.size_), + deletedCount_(other.deletedCount_), + usePSRAM_(other.usePSRAM_) { + other.entries_ = nullptr; + other.capacity_ = 0; + other.size_ = 0; + other.deletedCount_ = 0; + } + + SchedulerIdIndex &operator=(SchedulerIdIndex &&other) noexcept { + if (this == &other) { + return *this; + } + schedulerDeallocate(entries_); + entries_ = other.entries_; + capacity_ = other.capacity_; + size_ = other.size_; + deletedCount_ = other.deletedCount_; + usePSRAM_ = other.usePSRAM_; + other.entries_ = nullptr; + other.capacity_ = 0; + other.size_ = 0; + other.deletedCount_ = 0; + return *this; + } + + private: + enum class EntryState : uint8_t { + Empty = 0, + Occupied, + Deleted, + }; + + struct Entry { + uint32_t jobId = 0; + std::size_t slotIndex = 0; + EntryState state = EntryState::Empty; + }; + + static std::size_t nextCapacity(std::size_t current) { + return current == 0 ? 8 : current * 2; + } + + static std::size_t hash(uint32_t jobId) { + return static_cast(jobId * 2654435761u); + } + + bool ensureCapacityForInsert() { + if (capacity_ == 0) { + return rehash(nextCapacity(capacity_)); + } + if ((size_ + deletedCount_ + 1) * 10 >= capacity_ * 7) { + return rehash(nextCapacity(capacity_)); + } + if (deletedCount_ > size_) { + return rehash(capacity_); + } + return true; + } + + bool insertOrAssign(uint32_t jobId, std::size_t slotIndex) { + const std::size_t mask = capacity_ - 1; + std::size_t index = hash(jobId) & mask; + std::size_t firstDeleted = capacity_; + for (std::size_t probe = 0; probe < capacity_; ++probe) { + Entry &entry = entries_[index]; + if (entry.state == EntryState::Empty) { + if (firstDeleted != capacity_) { + index = firstDeleted; + } + Entry &target = entries_[index]; + target.jobId = jobId; + target.slotIndex = slotIndex; + if (target.state == EntryState::Deleted) { + --deletedCount_; + } + target.state = EntryState::Occupied; + ++size_; + return true; + } + if (entry.state == EntryState::Deleted) { + if (firstDeleted == capacity_) { + firstDeleted = index; + } + } else if (entry.jobId == jobId) { + entry.slotIndex = slotIndex; + return true; + } + index = (index + 1) & mask; + } + if (firstDeleted != capacity_) { + Entry &target = entries_[firstDeleted]; + target.jobId = jobId; + target.slotIndex = slotIndex; + target.state = EntryState::Occupied; + --deletedCount_; + ++size_; + return true; + } + return false; + } + + bool rehash(std::size_t newCapacity) { + Entry *next = schedulerAllocate(newCapacity, usePSRAM_); + if (!next) { + return false; + } + for (std::size_t index = 0; index < newCapacity; ++index) { + next[index] = Entry{}; + } + + Entry *previous = entries_; + const std::size_t previousCapacity = capacity_; + entries_ = next; + capacity_ = newCapacity; + const std::size_t previousSize = size_; + size_ = 0; + deletedCount_ = 0; + + for (std::size_t index = 0; index < previousCapacity; ++index) { + const Entry &entry = previous[index]; + if (entry.state == EntryState::Occupied && !insertOrAssign(entry.jobId, entry.slotIndex)) { + schedulerDeallocate(next); + entries_ = previous; + capacity_ = previousCapacity; + size_ = previousSize; + deletedCount_ = 0; + for (std::size_t restore = 0; restore < previousCapacity; ++restore) { + if (previous[restore].state == EntryState::Deleted) { + ++deletedCount_; + } + } + return false; + } + } + schedulerDeallocate(previous); + return true; + } + + Entry *entries_ = nullptr; + std::size_t capacity_ = 0; + std::size_t size_ = 0; + std::size_t deletedCount_ = 0; + bool usePSRAM_ = false; +}; diff --git a/src/esp_scheduler/core/scheduler_core.cpp b/src/esp_scheduler/core/scheduler_core.cpp new file mode 100644 index 0000000..83ca8b6 --- /dev/null +++ b/src/esp_scheduler/core/scheduler_core.cpp @@ -0,0 +1,540 @@ +#include "scheduler_core.h" + +namespace { +constexpr int64_t kRetryDelaySeconds = 1; + +CallbackRef makeEmptyCallback() { + return CallbackRef{}; +} +} // namespace + +SchedulerCore::SchedulerCore(ESPDate &date, int64_t minValidEpochSeconds, bool usePSRAMMetadata) + : date_(date), + minValidEpochSeconds_(minValidEpochSeconds), + usePSRAMMetadata_(usePSRAMMetadata), + jobs_(usePSRAMMetadata), + freeSlots_(usePSRAMMetadata), + pendingSchedules_(usePSRAMMetadata), + jobIndex_(usePSRAMMetadata), + dueHeap_(usePSRAMMetadata) { +} + +void SchedulerCore::setMinValidUnixSeconds(int64_t minEpochSeconds) { + minValidEpochSeconds_ = minEpochSeconds; +} + +int64_t SchedulerCore::minValidUnixSeconds() const { + return minValidEpochSeconds_; +} + +bool SchedulerCore::clockValid(const DateTime &nowUtc) const { + return nowUtc.epochSeconds >= minValidEpochSeconds_; +} + +SchedulerResult SchedulerCore::findJobSlot(uint32_t jobId) const { + size_t slotIndex = 0; + if (!jobIndex_.get(jobId, slotIndex) || slotIndex >= jobs_.size()) { + return SchedulerResult::failure(SchedulerError::NotFound); + } + const JobRecord &record = jobs_[slotIndex]; + if (!record.occupied || record.canceled || record.id != jobId) { + return SchedulerResult::failure(SchedulerError::NotFound); + } + return SchedulerResult::success(slotIndex); +} + +bool SchedulerCore::computeNextForJob(JobRecord &record, const DateTime &fromUtc) { + if (record.canceled || record.paused) { + record.hasNext = false; + return false; + } + if (record.schedule.isOneShot || record.schedule.kind == ScheduleKind::OneShotUtc) { + record.nextRunUtc = record.schedule.onceAtUtc; + record.hasNext = true; + return true; + } + record.hasNext = + ScheduleCalculator::computeNext(date_, record.schedule, fromUtc, record.nextRunUtc); + return record.hasNext; +} + +bool SchedulerCore::pushDue(size_t slotIndex, const JobRecord &record) { + if (!record.occupied || record.canceled || !record.hasNext) { + return true; + } + return dueHeap_.push({record.nextRunUtc.epochSeconds, slotIndex, record.generation}); +} + +bool SchedulerCore::queueScheduling(size_t slotIndex, const DateTime &fromUtc) { + if (slotIndex >= jobs_.size()) { + return false; + } + JobRecord &record = jobs_[slotIndex]; + if (!record.occupied || record.canceled || record.paused) { + record.pendingSchedule = false; + record.hasNext = false; + return true; + } + record.scheduleFromUtc = fromUtc; + record.hasNext = false; + if (record.pendingSchedule) { + return true; + } + record.pendingSchedule = true; + if (!pendingSchedules_.pushBack(slotIndex)) { + record.pendingSchedule = false; + return false; + } + return true; +} + +void SchedulerCore::clearScheduling(size_t slotIndex) { + if (slotIndex >= jobs_.size()) { + return; + } + JobRecord &record = jobs_[slotIndex]; + record.pendingSchedule = false; + record.hasNext = false; +} + +void SchedulerCore::drainPendingSchedules(const DateTime &nowUtc) { + if (!clockValid(nowUtc)) { + return; + } + while (!pendingSchedules_.empty()) { + const size_t slotIndex = pendingSchedules_[pendingSchedules_.size() - 1]; + pendingSchedules_.popBack(); + if (slotIndex >= jobs_.size()) { + continue; + } + JobRecord &record = jobs_[slotIndex]; + record.pendingSchedule = false; + if (!record.occupied || record.canceled || record.paused || record.runningCount > 0) { + record.hasNext = false; + continue; + } + if (computeNextForJob(record, record.scheduleFromUtc) && !pushDue(slotIndex, record)) { + record.hasNext = false; + } + } +} + +bool SchedulerCore::validateDueEntry(const DueHeapEntry &entry) const { + if (entry.slotIndex >= jobs_.size()) { + return false; + } + const JobRecord &record = jobs_[entry.slotIndex]; + return record.occupied && !record.canceled && record.hasNext && + record.generation == entry.generation && + record.nextRunUtc.epochSeconds == entry.nextEpoch; +} + +void SchedulerCore::pruneInvalidDueEntries() { + while (!dueHeap_.empty() && !validateDueEntry(dueHeap_.top())) { + dueHeap_.pop(); + } +} + +void SchedulerCore::retireJob(size_t slotIndex) { + if (slotIndex >= jobs_.size()) { + return; + } + JobRecord &record = jobs_[slotIndex]; + if (record.id != 0) { + jobIndex_.remove(record.id); + } + record.occupied = false; + record.canceled = false; + record.paused = false; + record.queuedWhileRunning = false; + record.hasNext = false; + record.pendingSchedule = false; + record.runningCount = 0; + record.callback = makeEmptyCallback(); + record.name.clear(); + record.id = 0; + record.generation++; + freeSlots_.pushBack(slotIndex); +} + +void SchedulerCore::finalizeCanceledIfIdle(size_t slotIndex) { + if (slotIndex >= jobs_.size()) { + return; + } + JobRecord &record = jobs_[slotIndex]; + if (record.occupied && record.canceled && record.runningCount == 0) { + retireJob(slotIndex); + } +} + +SchedulerResult SchedulerCore::addJob( + const ScheduleSpec &schedule, + const JobOptions &options, + const CallbackRef &callback, + const DateTime &nowUtc +) { + if (!callback.valid()) { + return SchedulerResult::failure(SchedulerError::InvalidSchedule); + } + if (!ScheduleCalculator::validate(schedule)) { + return SchedulerResult::failure(SchedulerError::InvalidSchedule); + } + + JobRecord record(usePSRAMMetadata_); + record.occupied = true; + record.id = nextId_++; + record.schedule = schedule; + record.dispatch = options.dispatch; + record.overlap = options.overlap; + record.executorId = options.executorId; + record.paused = options.startPaused; + record.callback = callback; + record.hasDedicatedTaskOptions = options.dedicatedTask != nullptr; + if (!record.name.assign(options.name)) { + return SchedulerResult::failure(SchedulerError::NoMemory); + } + if (options.dedicatedTask) { + record.dedicatedTask = *options.dedicatedTask; + } + if (clockValid(nowUtc) && !record.paused) { + computeNextForJob(record, nowUtc); + } + + size_t slotIndex = 0; + bool reusedSlot = false; + uint32_t reusedGeneration = 0; + if (!freeSlots_.empty()) { + slotIndex = freeSlots_[freeSlots_.size() - 1]; + freeSlots_.popBack(); + reusedGeneration = jobs_[slotIndex].generation; + record.generation = reusedGeneration; + jobs_[slotIndex] = std::move(record); + reusedSlot = true; + } else { + slotIndex = jobs_.size(); + if (!jobs_.pushBack(std::move(record))) { + return SchedulerResult::failure(SchedulerError::NoMemory); + } + } + + if (!jobIndex_.set(jobs_[slotIndex].id, slotIndex) || + (!jobs_[slotIndex].paused && !queueScheduling(slotIndex, nowUtc))) { + jobIndex_.remove(jobs_[slotIndex].id); + if (reusedSlot) { + JobRecord empty(usePSRAMMetadata_); + empty.generation = reusedGeneration; + jobs_[slotIndex] = std::move(empty); + freeSlots_.pushBack(slotIndex); + } else { + jobs_.popBack(); + } + return SchedulerResult::failure(SchedulerError::NoMemory); + } + return SchedulerResult::success(jobs_[slotIndex].id); +} + +SchedulerResult SchedulerCore::cancelJob(uint32_t jobId) { + SchedulerResult slotResult = findJobSlot(jobId); + if (!slotResult.ok()) { + return SchedulerResult::failure(slotResult.error); + } + JobRecord &record = jobs_[slotResult.value]; + record.canceled = true; + record.paused = false; + record.queuedWhileRunning = false; + clearScheduling(slotResult.value); + finalizeCanceledIfIdle(slotResult.value); + return SchedulerResult::success(); +} + +SchedulerResult SchedulerCore::pauseJob(uint32_t jobId) { + SchedulerResult slotResult = findJobSlot(jobId); + if (!slotResult.ok()) { + return SchedulerResult::failure(slotResult.error); + } + JobRecord &record = jobs_[slotResult.value]; + record.paused = true; + record.queuedWhileRunning = false; + clearScheduling(slotResult.value); + return SchedulerResult::success(); +} + +SchedulerResult SchedulerCore::resumeJob(uint32_t jobId, const DateTime &nowUtc) { + SchedulerResult slotResult = findJobSlot(jobId); + if (!slotResult.ok()) { + return SchedulerResult::failure(slotResult.error); + } + JobRecord &record = jobs_[slotResult.value]; + record.paused = false; + record.queuedWhileRunning = false; + if (record.runningCount == 0 && !queueScheduling(slotResult.value, nowUtc)) { + return SchedulerResult::failure(SchedulerError::NoMemory); + } + return SchedulerResult::success(); +} + +SchedulerResult SchedulerCore::cancelAll() { + for (size_t index = 0; index < jobs_.size(); ++index) { + JobRecord &record = jobs_[index]; + if (!record.occupied || record.canceled) { + continue; + } + record.canceled = true; + record.paused = false; + record.queuedWhileRunning = false; + clearScheduling(index); + finalizeCanceledIfIdle(index); + } + return SchedulerResult::success(); +} + +SchedulerResult SchedulerCore::jobCount() const { + size_t count = 0; + for (size_t index = 0; index < jobs_.size(); ++index) { + const JobRecord &record = jobs_[index]; + if (record.occupied && !record.canceled) { + ++count; + } + } + return SchedulerResult::success(count); +} + +SchedulerResult SchedulerCore::getJobInfo(uint32_t jobId, JobInfo &out) const { + SchedulerResult slotResult = findJobSlot(jobId); + if (!slotResult.ok()) { + out = JobInfo{}; + return SchedulerResult::failure(slotResult.error); + } + + const JobRecord &record = jobs_[slotResult.value]; + out = JobInfo{}; + out.id = record.id; + out.name = record.name.empty() ? nullptr : record.name.c_str(); + out.paused = record.paused; + out.running = record.runningCount > 0; + out.queuedWhileRunning = record.queuedWhileRunning; + out.dispatch = record.dispatch; + out.overlap = record.overlap; + out.executorId = record.executorId; + out.hasNext = record.hasNext; + out.nextRunUtc = record.nextRunUtc; + out.schedule = record.schedule; + return SchedulerResult::success(); +} + +void SchedulerCore::dispatchOne( + size_t slotIndex, const DateTime &nowUtc, IExecutorResolver &executors, bool deferred +) { + if (slotIndex >= jobs_.size()) { + return; + } + JobRecord &record = jobs_[slotIndex]; + if (!record.occupied || record.canceled || record.paused) { + return; + } + const DateTime currentDue = record.nextRunUtc; + + JobInvocation invocation{}; + invocation.jobId = record.id; + invocation.generation = record.generation; + invocation.slotIndex = slotIndex; + invocation.name = record.name.empty() ? nullptr : record.name.c_str(); + invocation.callback = record.callback; + invocation.dedicatedTask = + record.hasDedicatedTaskOptions ? record.dedicatedTask : DedicatedTaskOptions{}; + + if (record.dispatch == DispatchPolicy::Inline) { + ISchedulerExecutor *executor = executors.inlineExecutor(); + if (!executor) { + record.hasNext = true; + record.nextRunUtc = date_.addSeconds(nowUtc, kRetryDelaySeconds); + if (!pushDue(slotIndex, record)) { + record.hasNext = false; + } + return; + } + record.runningCount++; + if (record.schedule.isOneShot || record.schedule.kind == ScheduleKind::OneShotUtc) { + record.hasNext = false; + } else if (computeNextForJob(record, date_.addMinutes(currentDue, 1))) { + if (!pushDue(slotIndex, record)) { + record.hasNext = false; + } + } else { + record.hasNext = false; + } + if (!executor->submit(invocation)) { + record.runningCount--; + record.hasNext = true; + record.nextRunUtc = date_.addSeconds(nowUtc, kRetryDelaySeconds); + if (!pushDue(slotIndex, record)) { + record.hasNext = false; + } + return; + } + handleCompletion(slotIndex, nowUtc, executors); + return; + } + + ISchedulerExecutor *executor = executors.executorFor(record.executorId); + if (!executor) { + record.hasNext = true; + record.nextRunUtc = date_.addSeconds(nowUtc, kRetryDelaySeconds); + if (!pushDue(slotIndex, record)) { + record.hasNext = false; + } + return; + } + + if (record.hasDedicatedTaskOptions && record.executorId != 1) { + record.hasNext = true; + record.nextRunUtc = date_.addSeconds(nowUtc, kRetryDelaySeconds); + if (!pushDue(slotIndex, record)) { + record.hasNext = false; + } + return; + } + + if (!executor->submit(invocation)) { + record.hasNext = true; + record.nextRunUtc = date_.addSeconds(nowUtc, kRetryDelaySeconds); + if (!pushDue(slotIndex, record)) { + record.hasNext = false; + } + return; + } + + record.runningCount++; + if (deferred || record.schedule.isOneShot || record.schedule.kind == ScheduleKind::OneShotUtc) { + record.hasNext = false; + return; + } + if (record.overlap == OverlapPolicy::AllowParallel) { + if (computeNextForJob(record, date_.addMinutes(currentDue, 1))) { + if (!pushDue(slotIndex, record)) { + record.hasNext = false; + } + } + return; + } + if (computeNextForJob(record, date_.addMinutes(currentDue, 1))) { + if (!pushDue(slotIndex, record)) { + record.hasNext = false; + } + } +} + +void SchedulerCore::dispatchDeferredIfNeeded( + size_t slotIndex, const DateTime &nowUtc, IExecutorResolver &executors +) { + if (slotIndex >= jobs_.size()) { + return; + } + JobRecord &record = jobs_[slotIndex]; + if (!record.occupied || record.canceled || record.paused || !record.queuedWhileRunning || + record.runningCount != 0) { + return; + } + record.queuedWhileRunning = false; + dispatchOne(slotIndex, nowUtc, executors, true); +} + +void SchedulerCore::handleCompletion( + size_t slotIndex, const DateTime &nowUtc, IExecutorResolver &executors +) { + if (slotIndex >= jobs_.size()) { + return; + } + JobRecord &record = jobs_[slotIndex]; + if (!record.occupied) { + return; + } + if (record.runningCount > 0) { + record.runningCount--; + } + + if (record.canceled) { + finalizeCanceledIfIdle(slotIndex); + return; + } + + if (record.queuedWhileRunning && record.runningCount == 0) { + dispatchDeferredIfNeeded(slotIndex, nowUtc, executors); + return; + } + + if (record.runningCount == 0 && !record.paused && !record.hasNext && + !(record.schedule.isOneShot || record.schedule.kind == ScheduleKind::OneShotUtc)) { + (void)queueScheduling(slotIndex, nowUtc); + } + + if (record.runningCount == 0 && !record.hasNext && + (record.schedule.isOneShot || record.schedule.kind == ScheduleKind::OneShotUtc)) { + retireJob(slotIndex); + } +} + +void SchedulerCore::dispatchDue(const DateTime &nowUtc, IExecutorResolver &executors) { + if (!clockValid(nowUtc)) { + return; + } + drainPendingSchedules(nowUtc); + pruneInvalidDueEntries(); + while (!dueHeap_.empty()) { + pruneInvalidDueEntries(); + if (dueHeap_.empty()) { + break; + } + const DueHeapEntry entry = dueHeap_.top(); + if (entry.nextEpoch > nowUtc.epochSeconds) { + break; + } + dueHeap_.pop(); + JobRecord &record = jobs_[entry.slotIndex]; + if (record.paused) { + record.hasNext = false; + continue; + } + if (record.runningCount > 0 && record.overlap != OverlapPolicy::AllowParallel) { + if (record.overlap == OverlapPolicy::QueueOne) { + record.queuedWhileRunning = true; + } + record.hasNext = false; + continue; + } + dispatchOne(entry.slotIndex, nowUtc, executors, false); + } +} + +void SchedulerCore::handleEvent( + const SchedulerEvent &event, const DateTime &nowUtc, IExecutorResolver &executors +) { + if (event.kind != SchedulerEventKind::JobFinished) { + return; + } + if (event.slotIndex >= jobs_.size()) { + return; + } + const JobRecord &record = jobs_[event.slotIndex]; + if (!record.occupied || record.id != event.jobId || record.generation != event.generation) { + return; + } + handleCompletion(event.slotIndex, nowUtc, executors); +} + +bool SchedulerCore::nextDueEpoch(int64_t &outEpochSeconds) { + pruneInvalidDueEntries(); + if (dueHeap_.empty()) { + return false; + } + outEpochSeconds = dueHeap_.top().nextEpoch; + return true; +} + +size_t SchedulerCore::activeInvocationCount() const { + size_t count = 0; + for (size_t index = 0; index < jobs_.size(); ++index) { + count += jobs_[index].runningCount; + } + return count; +} diff --git a/src/esp_scheduler/core/scheduler_core.h b/src/esp_scheduler/core/scheduler_core.h new file mode 100644 index 0000000..8e6e1c7 --- /dev/null +++ b/src/esp_scheduler/core/scheduler_core.h @@ -0,0 +1,68 @@ +#pragma once + +#include "../schedule/schedule_calculator.h" +#include "../scheduler_result.h" +#include "../service/scheduler_events.h" +#include "due_heap.h" +#include "job_record.h" + +class SchedulerCore { + public: + SchedulerCore(ESPDate &date, int64_t minValidEpochSeconds, bool usePSRAMMetadata); + + void setMinValidUnixSeconds(int64_t minEpochSeconds); + int64_t minValidUnixSeconds() const; + bool clockValid(const DateTime &nowUtc) const; + + SchedulerResult addJob( + const ScheduleSpec &schedule, + const JobOptions &options, + const CallbackRef &callback, + const DateTime &nowUtc + ); + SchedulerResult cancelJob(uint32_t jobId); + SchedulerResult pauseJob(uint32_t jobId); + SchedulerResult resumeJob(uint32_t jobId, const DateTime &nowUtc); + SchedulerResult cancelAll(); + SchedulerResult jobCount() const; + SchedulerResult getJobInfo(uint32_t jobId, JobInfo &out) const; + + void dispatchDue(const DateTime &nowUtc, IExecutorResolver &executors); + void handleEvent( + const SchedulerEvent &event, const DateTime &nowUtc, IExecutorResolver &executors + ); + + bool nextDueEpoch(int64_t &outEpochSeconds); + size_t activeInvocationCount() const; + + private: + SchedulerResult findJobSlot(uint32_t jobId) const; + bool computeNextForJob(JobRecord &record, const DateTime &fromUtc); + bool pushDue(size_t slotIndex, const JobRecord &record); + bool queueScheduling(size_t slotIndex, const DateTime &fromUtc); + void clearScheduling(size_t slotIndex); + void drainPendingSchedules(const DateTime &nowUtc); + bool validateDueEntry(const DueHeapEntry &entry) const; + void pruneInvalidDueEntries(); + void retireJob(size_t slotIndex); + void finalizeCanceledIfIdle(size_t slotIndex); + void dispatchDeferredIfNeeded( + size_t slotIndex, const DateTime &nowUtc, IExecutorResolver &executors + ); + void dispatchOne( + size_t slotIndex, const DateTime &nowUtc, IExecutorResolver &executors, bool deferred + ); + void handleCompletion( + size_t slotIndex, const DateTime &nowUtc, IExecutorResolver &executors + ); + + ESPDate &date_; + int64_t minValidEpochSeconds_ = 0; + bool usePSRAMMetadata_ = false; + uint32_t nextId_ = 1; + SchedulerArray jobs_{}; + SchedulerArray freeSlots_{}; + SchedulerArray pendingSchedules_{}; + SchedulerIdIndex jobIndex_{}; + DueHeap dueHeap_{}; +}; diff --git a/src/esp_scheduler/executors/dedicated_task_executor.cpp b/src/esp_scheduler/executors/dedicated_task_executor.cpp new file mode 100644 index 0000000..9a2a136 --- /dev/null +++ b/src/esp_scheduler/executors/dedicated_task_executor.cpp @@ -0,0 +1,91 @@ +#include "dedicated_task_executor.h" + +#include + +#include "../service/scheduler_events.h" +#include "task_support.h" + +namespace { +bool postCompletion( + const std::shared_ptr &runtime, + uint32_t jobId, + uint32_t generation, + size_t slotIndex +) { + if (!runtime || !runtime->accepting.load() || runtime->eventQueue == nullptr) { + return false; + } + SchedulerEvent event{}; + event.kind = SchedulerEventKind::JobFinished; + event.jobId = jobId; + event.generation = generation; + event.slotIndex = slotIndex; + return xQueueSend(runtime->eventQueue, &event, 0) == pdTRUE; +} +} // namespace + +struct DedicatedTaskExecutor::TaskContext { + JobInvocation invocation{}; + bool createdWithCaps = false; +}; + +bool DedicatedTaskExecutor::begin(const std::shared_ptr &runtime) { + runtime_ = runtime; + return true; +} + +void DedicatedTaskExecutor::end(bool drainRunningJobs) { + (void)drainRunningJobs; + runtime_.reset(); +} + +bool DedicatedTaskExecutor::submit(const JobInvocation &invocation) { + TaskContext *context = new (std::nothrow) TaskContext{}; + if (!context) { + return false; + } + context->invocation = invocation; + context->invocation.runtime = runtime_; + + TaskHandle_t handle = nullptr; + const DedicatedTaskOptions &task = invocation.dedicatedTask; + const BaseType_t created = scheduler_task_support::createTaskPinned( + &DedicatedTaskExecutor::taskEntry, + task.name ? task.name : "sched-task", + task.stackSize, + context, + task.priority, + &handle, + task.coreId, + task.usePsramStack, + context->createdWithCaps + ); + if (created != pdPASS || handle == nullptr) { + delete context; + return false; + } + return true; +} + +const char *DedicatedTaskExecutor::name() const { + return "dedicated-task"; +} + +void DedicatedTaskExecutor::taskEntry(void *arg) { + TaskContext *context = static_cast(arg); + if (!context) { + vTaskDelete(nullptr); + return; + } + + context->invocation.callback.invoke(); + postCompletion( + context->invocation.runtime, + context->invocation.jobId, + context->invocation.generation, + context->invocation.slotIndex + ); + const bool createdWithCaps = context->createdWithCaps; + delete context; + scheduler_task_support::deleteCurrentTask(createdWithCaps); +} diff --git a/src/esp_scheduler/executors/dedicated_task_executor.h b/src/esp_scheduler/executors/dedicated_task_executor.h new file mode 100644 index 0000000..ef16cac --- /dev/null +++ b/src/esp_scheduler/executors/dedicated_task_executor.h @@ -0,0 +1,21 @@ +#pragma once + +#include "scheduler_executor.h" + +class DedicatedTaskExecutor : public ISchedulerExecutor { + public: + DedicatedTaskExecutor() = default; + ~DedicatedTaskExecutor() override = default; + + bool begin(const std::shared_ptr &runtime) override; + void end(bool drainRunningJobs) override; + bool submit(const JobInvocation &invocation) override; + const char *name() const override; + + private: + struct TaskContext; + + static void taskEntry(void *arg); + + std::shared_ptr runtime_{}; +}; diff --git a/src/esp_scheduler/executors/esp_worker_executor.cpp b/src/esp_scheduler/executors/esp_worker_executor.cpp new file mode 100644 index 0000000..f0fa660 --- /dev/null +++ b/src/esp_scheduler/executors/esp_worker_executor.cpp @@ -0,0 +1,62 @@ +#include "esp_worker_executor.h" + +#include + +#include "../service/scheduler_events.h" + +namespace { +bool postCompletion( + const std::shared_ptr &runtime, + uint32_t jobId, + uint32_t generation, + size_t slotIndex +) { + if (!runtime || !runtime->accepting.load() || runtime->eventQueue == nullptr) { + return false; + } + SchedulerEvent event{}; + event.kind = SchedulerEventKind::JobFinished; + event.jobId = jobId; + event.generation = generation; + event.slotIndex = slotIndex; + return xQueueSend(runtime->eventQueue, &event, 0) == pdTRUE; +} +} // namespace + +ESPWorkerExecutorAdapter::ESPWorkerExecutorAdapter(ESPWorker &worker) : worker_(worker) { +} + +bool ESPWorkerExecutorAdapter::begin(const std::shared_ptr &runtime) { + runtime_ = runtime; + return true; +} + +void ESPWorkerExecutorAdapter::end(bool drainRunningJobs) { + (void)drainRunningJobs; + runtime_.reset(); +} + +bool ESPWorkerExecutorAdapter::submit(const JobInvocation &invocation) { + WorkerConfig config{}; + if (invocation.name) { + config.name = invocation.name; + } + std::shared_ptr runtime = runtime_; + WorkerResult result = worker_.spawn( + [invocation, runtime]() { + invocation.callback.invoke(); + postCompletion( + runtime, + invocation.jobId, + invocation.generation, + invocation.slotIndex + ); + }, + config + ); + return static_cast(result); +} + +const char *ESPWorkerExecutorAdapter::name() const { + return "esp-worker"; +} diff --git a/src/esp_scheduler/executors/esp_worker_executor.h b/src/esp_scheduler/executors/esp_worker_executor.h new file mode 100644 index 0000000..4c00921 --- /dev/null +++ b/src/esp_scheduler/executors/esp_worker_executor.h @@ -0,0 +1,19 @@ +#pragma once + +#include "scheduler_executor.h" + +class ESPWorker; + +class ESPWorkerExecutorAdapter : public ISchedulerExecutor { + public: + explicit ESPWorkerExecutorAdapter(ESPWorker &worker); + + bool begin(const std::shared_ptr &runtime) override; + void end(bool drainRunningJobs) override; + bool submit(const JobInvocation &invocation) override; + const char *name() const override; + + private: + ESPWorker &worker_; + std::shared_ptr runtime_{}; +}; diff --git a/src/esp_scheduler/executors/inline_executor.cpp b/src/esp_scheduler/executors/inline_executor.cpp new file mode 100644 index 0000000..eac7be6 --- /dev/null +++ b/src/esp_scheduler/executors/inline_executor.cpp @@ -0,0 +1,20 @@ +#include "inline_executor.h" + +bool InlineExecutor::begin(const std::shared_ptr &runtime) { + runtime_ = runtime; + return true; +} + +void InlineExecutor::end(bool drainRunningJobs) { + (void)drainRunningJobs; + runtime_.reset(); +} + +bool InlineExecutor::submit(const JobInvocation &invocation) { + invocation.callback.invoke(); + return true; +} + +const char *InlineExecutor::name() const { + return "inline"; +} diff --git a/src/esp_scheduler/executors/inline_executor.h b/src/esp_scheduler/executors/inline_executor.h new file mode 100644 index 0000000..6a4f3bf --- /dev/null +++ b/src/esp_scheduler/executors/inline_executor.h @@ -0,0 +1,14 @@ +#pragma once + +#include "scheduler_executor.h" + +class InlineExecutor : public ISchedulerExecutor { + public: + bool begin(const std::shared_ptr &runtime) override; + void end(bool drainRunningJobs) override; + bool submit(const JobInvocation &invocation) override; + const char *name() const override; + + private: + std::shared_ptr runtime_{}; +}; diff --git a/src/esp_scheduler/executors/scheduler_executor.h b/src/esp_scheduler/executors/scheduler_executor.h new file mode 100644 index 0000000..ab705e3 --- /dev/null +++ b/src/esp_scheduler/executors/scheduler_executor.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include + +#include "../scheduler.h" + +extern "C" { +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +} + +struct SchedulerExecutorRuntime { + std::atomic accepting{true}; + QueueHandle_t eventQueue = nullptr; +}; + +enum class CallbackKind : uint8_t { + RawFunction = 0, + OwningFunction, +}; + +struct CallbackRef { + CallbackKind kind = CallbackKind::RawFunction; + SchedulerCallbackFn rawFn = nullptr; + void *userData = nullptr; + std::shared_ptr owningFn{}; + + bool valid() const { + return rawFn != nullptr || static_cast(owningFn); + } + + void invoke() const { + if (owningFn) { + (*owningFn)(userData); + return; + } + if (rawFn) { + rawFn(userData); + } + } +}; + +struct JobInvocation { + uint32_t jobId = 0; + uint32_t generation = 0; + size_t slotIndex = 0; + const char *name = nullptr; + CallbackRef callback{}; + DedicatedTaskOptions dedicatedTask{}; + std::shared_ptr runtime{}; +}; + +class ISchedulerExecutor { + public: + virtual ~ISchedulerExecutor() = default; + + virtual bool begin(const std::shared_ptr &runtime) = 0; + virtual void end(bool drainRunningJobs) = 0; + virtual bool submit(const JobInvocation &invocation) = 0; + virtual const char *name() const = 0; +}; + +class IExecutorResolver { + public: + virtual ~IExecutorResolver() = default; + virtual ISchedulerExecutor *inlineExecutor() = 0; + virtual ISchedulerExecutor *executorFor(uint8_t executorId) = 0; +}; diff --git a/src/esp_scheduler/executors/task_support.h b/src/esp_scheduler/executors/task_support.h new file mode 100644 index 0000000..82f7221 --- /dev/null +++ b/src/esp_scheduler/executors/task_support.h @@ -0,0 +1,116 @@ +#pragma once + +#include + +extern "C" { +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_heap_caps.h" +} + +#if __has_include("freertos/idf_additions.h") +extern "C" { +#include "freertos/idf_additions.h" +} +#define ESP_SCHEDULER_HAS_IDF_TASK_CAPS 1 +#else +#define ESP_SCHEDULER_HAS_IDF_TASK_CAPS 0 +#endif + +#if ESP_SCHEDULER_HAS_IDF_TASK_CAPS && defined(configSUPPORT_STATIC_ALLOCATION) && \ + (configSUPPORT_STATIC_ALLOCATION == 1) && defined(MALLOC_CAP_SPIRAM) +#define ESP_SCHEDULER_CAN_USE_EXTERNAL_STACKS 1 +#else +#define ESP_SCHEDULER_CAN_USE_EXTERNAL_STACKS 0 +#endif + +namespace scheduler_task_support { +constexpr size_t kMinStackSizeBytes = 1024; +#if defined(MALLOC_CAP_SPIRAM) +constexpr UBaseType_t kExternalStackCaps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; +#else +constexpr UBaseType_t kExternalStackCaps = MALLOC_CAP_8BIT; +#endif + +inline bool hasExternalStackSupport() { +#if ESP_SCHEDULER_CAN_USE_EXTERNAL_STACKS + return heap_caps_get_total_size(MALLOC_CAP_SPIRAM) > 0; +#else + return false; +#endif +} + +inline bool isValidStackSize(size_t stackBytes) { + return stackBytes >= kMinStackSizeBytes && (stackBytes % sizeof(StackType_t)) == 0; +} + +inline BaseType_t createTaskPinned( + TaskFunction_t entry, + const char *name, + size_t stackBytes, + void *arg, + UBaseType_t priority, + TaskHandle_t *handle, + BaseType_t coreId, + bool usePsramStack, + bool &createdWithCaps +) { + createdWithCaps = false; + if (!isValidStackSize(stackBytes)) { + return pdFAIL; + } + if (usePsramStack) { +#if ESP_SCHEDULER_CAN_USE_EXTERNAL_STACKS + if (!hasExternalStackSupport()) { + return pdFAIL; + } + BaseType_t created = xTaskCreatePinnedToCoreWithCaps( + entry, + name, + static_cast(stackBytes), + arg, + priority, + handle, + coreId, + kExternalStackCaps + ); + createdWithCaps = (created == pdPASS); + return created; +#else + return pdFAIL; +#endif + } + return xTaskCreatePinnedToCore( + entry, + name, + static_cast(stackBytes), + arg, + priority, + handle, + coreId + ); +} + +inline void deleteTask(TaskHandle_t taskHandle, bool withCaps) { + if (!taskHandle) { + return; + } +#if ESP_SCHEDULER_CAN_USE_EXTERNAL_STACKS + if (withCaps) { + vTaskDeleteWithCaps(taskHandle); + return; + } +#endif + vTaskDelete(taskHandle); +} + +inline void deleteCurrentTask(bool withCaps) { +#if ESP_SCHEDULER_CAN_USE_EXTERNAL_STACKS + if (withCaps) { + vTaskDeleteWithCaps(xTaskGetCurrentTaskHandle()); + return; + } +#endif + vTaskDelete(nullptr); +} +} // namespace scheduler_task_support diff --git a/src/esp_scheduler/executors/worker_pool_executor.cpp b/src/esp_scheduler/executors/worker_pool_executor.cpp new file mode 100644 index 0000000..47f4083 --- /dev/null +++ b/src/esp_scheduler/executors/worker_pool_executor.cpp @@ -0,0 +1,194 @@ +#include "worker_pool_executor.h" + +#include + +#include "../service/scheduler_events.h" +#include "task_support.h" + +namespace { +bool postCompletion( + const std::shared_ptr &runtime, + uint32_t jobId, + uint32_t generation, + size_t slotIndex +) { + if (!runtime || !runtime->accepting.load() || runtime->eventQueue == nullptr) { + return false; + } + SchedulerEvent event{}; + event.kind = SchedulerEventKind::JobFinished; + event.jobId = jobId; + event.generation = generation; + event.slotIndex = slotIndex; + return xQueueSend(runtime->eventQueue, &event, 0) == pdTRUE; +} +} // namespace + +struct WorkerPoolExecutor::TaskItem { + JobInvocation invocation{}; +}; + +struct WorkerPoolExecutor::WorkerContext { + WorkerPoolExecutor *owner = nullptr; + bool createdWithCaps = false; +}; + +WorkerPoolExecutor::WorkerPoolExecutor(const WorkerPoolConfig &config) + : config_(config), workers_(false) { +} + +WorkerPoolExecutor::~WorkerPoolExecutor() { + end(true); +} + +bool WorkerPoolExecutor::begin(const std::shared_ptr &runtime) { + if (started_.load()) { + return true; + } + + runtime_ = runtime; + queue_ = xQueueCreate(config_.queueDepth, sizeof(TaskItem *)); + if (!queue_) { + return false; + } + + workers_.clear(); + workersRunning_.store(0); + for (uint8_t index = 0; index < config_.workerCount; ++index) { + WorkerContext *context = new (std::nothrow) WorkerContext{}; + if (!context) { + end(false); + return false; + } + context->owner = this; + + TaskHandle_t handle = nullptr; + bool createdWithCaps = false; + const BaseType_t created = scheduler_task_support::createTaskPinned( + &WorkerPoolExecutor::workerTaskEntry, + "sched-pool", + config_.stackSize, + context, + config_.priority, + &handle, + config_.coreId, + config_.usePsramStack, + createdWithCaps + ); + if (created != pdPASS || handle == nullptr) { + delete context; + end(false); + return false; + } + context->createdWithCaps = createdWithCaps; + if (!workers_.pushBack({handle, createdWithCaps})) { + scheduler_task_support::deleteTask(handle, createdWithCaps); + end(false); + return false; + } + workersRunning_.fetch_add(1); + } + + started_.store(true); + return true; +} + +void WorkerPoolExecutor::end(bool drainRunningJobs) { + if (!queue_) { + started_.store(false); + runtime_.reset(); + return; + } + + if (!drainRunningJobs) { + TaskItem *pending = nullptr; + while (xQueueReceive(queue_, &pending, 0) == pdTRUE) { + delete pending; + } + } + + for (size_t index = 0; index < workers_.size(); ++index) { + TaskItem *sentinel = nullptr; + xQueueSend(queue_, &sentinel, 0); + } + + const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(2000); + while (workersRunning_.load() > 0 && xTaskGetTickCount() < deadline) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + + if (workersRunning_.load() > 0) { + for (size_t index = 0; index < workers_.size(); ++index) { + scheduler_task_support::deleteTask( + workers_[index].task, + workers_[index].createdWithCaps + ); + } + } + + if (queue_) { + vQueueDelete(queue_); + queue_ = nullptr; + } + workers_.clear(); + started_.store(false); + runtime_.reset(); +} + +bool WorkerPoolExecutor::submit(const JobInvocation &invocation) { + if (!queue_ || !started_.load()) { + return false; + } + + TaskItem *item = new (std::nothrow) TaskItem{}; + if (!item) { + return false; + } + item->invocation = invocation; + item->invocation.runtime = runtime_; + + if (xQueueSend(queue_, &item, 0) != pdTRUE) { + delete item; + return false; + } + return true; +} + +const char *WorkerPoolExecutor::name() const { + return "worker-pool"; +} + +void WorkerPoolExecutor::workerTaskEntry(void *arg) { + WorkerContext *context = static_cast(arg); + if (!context || !context->owner) { + delete context; + vTaskDelete(nullptr); + return; + } + + WorkerPoolExecutor *owner = context->owner; + const bool createdWithCaps = context->createdWithCaps; + delete context; + + while (true) { + TaskItem *item = nullptr; + if (xQueueReceive(owner->queue_, &item, portMAX_DELAY) != pdTRUE) { + continue; + } + if (!item) { + break; + } + + item->invocation.callback.invoke(); + postCompletion( + owner->runtime_, + item->invocation.jobId, + item->invocation.generation, + item->invocation.slotIndex + ); + delete item; + } + + owner->workersRunning_.fetch_sub(1); + scheduler_task_support::deleteCurrentTask(createdWithCaps); +} diff --git a/src/esp_scheduler/executors/worker_pool_executor.h b/src/esp_scheduler/executors/worker_pool_executor.h new file mode 100644 index 0000000..c4f9ea5 --- /dev/null +++ b/src/esp_scheduler/executors/worker_pool_executor.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "../core/runtime_containers.h" +#include "scheduler_executor.h" + +class WorkerPoolExecutor : public ISchedulerExecutor { + public: + explicit WorkerPoolExecutor(const WorkerPoolConfig &config); + ~WorkerPoolExecutor() override; + + bool begin(const std::shared_ptr &runtime) override; + void end(bool drainRunningJobs) override; + bool submit(const JobInvocation &invocation) override; + const char *name() const override; + + private: + struct TaskItem; + struct WorkerContext; + struct WorkerHandle { + TaskHandle_t task = nullptr; + bool createdWithCaps = false; + }; + + static void workerTaskEntry(void *arg); + + WorkerPoolConfig config_{}; + std::shared_ptr runtime_{}; + QueueHandle_t queue_ = nullptr; + SchedulerArray workers_{}; + std::atomic started_{false}; + std::atomic workersRunning_{0}; +}; diff --git a/src/esp_scheduler/schedule/schedule_calculator.cpp b/src/esp_scheduler/schedule/schedule_calculator.cpp new file mode 100644 index 0000000..8ac3497 --- /dev/null +++ b/src/esp_scheduler/schedule/schedule_calculator.cpp @@ -0,0 +1,315 @@ +#include "schedule_calculator.h" + +#include + +namespace { +constexpr int64_t kMaxSearchMinutes = 366 * 24 * 60; +constexpr int64_t kMaxSunSearchDays = 732; +constexpr int64_t kMaxMoonSearchMinutes = 62 * 24 * 60; +constexpr int kMinSunOffsetMinutes = -1440; +constexpr int kMaxSunOffsetMinutes = 1440; +constexpr int kMinMoonPhaseAngle = 0; +constexpr int kMaxMoonPhaseAngle = 359; +constexpr int kMaxMoonPhaseTolerance = 30; +constexpr double kMinIlluminationPercent = 0.0; +constexpr double kMaxIlluminationPercent = 100.0; +constexpr double kMaxIlluminationTolerancePercent = 50.0; +constexpr double kFullCircleDegrees = 360.0; +constexpr double kComparisonEpsilon = 1e-9; + +ScheduleKind resolvedScheduleKind(const ScheduleSpec &spec) { + if (spec.kind == ScheduleKind::Cron && spec.isOneShot) { + return ScheduleKind::OneShotUtc; + } + return spec.kind; +} + +DateTime roundToNextMinute(ESPDate &date, const DateTime &fromUtc) { + DateTime rounded = fromUtc; + if (fromUtc.secondUtc() > 0) { + rounded = date.addMinutes(rounded, 1); + } + return date.setTimeOfDayUtc(rounded, rounded.hourUtc(), rounded.minuteUtc(), 0); +} + +uint64_t allowedMask(int min, int max) { + if (min < 0) { + min = 0; + } + if (max > 63) { + max = 63; + } + if (max >= 63) { + return ~static_cast(0); + } + const uint64_t upper = (1ULL << (max + 1)) - 1; + const uint64_t lower = min == 0 ? 0 : ((1ULL << min) - 1); + return upper & ~lower; +} + +bool fieldWithinRange(const ScheduleField &field, int min, int max) { + if (field.isAny()) { + return true; + } + const uint64_t mask = field.rawMask(); + const uint64_t allowed = allowedMask(min, max); + return mask != 0 && (mask & allowed) != 0; +} + +double normalizeAngle360(double angle) { + double normalized = std::fmod(angle, kFullCircleDegrees); + if (normalized < 0.0) { + normalized += kFullCircleDegrees; + } + return normalized; +} + +double unwrapAngle(double previousUnwrapped, double currentWrapped) { + const double previousWrapped = normalizeAngle360(previousUnwrapped); + double delta = currentWrapped - previousWrapped; + if (delta > 180.0) { + delta -= kFullCircleDegrees; + } else if (delta < -180.0) { + delta += kFullCircleDegrees; + } + return previousUnwrapped + delta; +} + +bool valueWithinPeriodicWindow(double value, double center, double tolerance) { + const double distance = std::fabs(value - center); + double wrapped = std::fmod(distance, kFullCircleDegrees); + if (wrapped < 0.0) { + wrapped += kFullCircleDegrees; + } + const double minimumDistance = std::min(wrapped, kFullCircleDegrees - wrapped); + return minimumDistance <= tolerance + kComparisonEpsilon; +} + +bool segmentIntersectsRange(double a, double b, double minValue, double maxValue) { + const double lo = std::min(a, b); + const double hi = std::max(a, b); + return hi >= minValue - kComparisonEpsilon && lo <= maxValue + kComparisonEpsilon; +} + +bool segmentIntersectsPeriodicWindow(double a, double b, double center, double tolerance) { + const double lo = std::min(a, b); + const double hi = std::max(a, b); + const int64_t firstPeriod = + static_cast(std::floor((lo - (center + tolerance)) / kFullCircleDegrees)) - 1; + const int64_t lastPeriod = + static_cast(std::ceil((hi - (center - tolerance)) / kFullCircleDegrees)) + 1; + for (int64_t period = firstPeriod; period <= lastPeriod; ++period) { + const double shift = static_cast(period) * kFullCircleDegrees; + const double minWindow = center - tolerance + shift; + const double maxWindow = center + tolerance + shift; + if (segmentIntersectsRange(lo, hi, minWindow, maxWindow)) { + return true; + } + } + return false; +} + +bool computeNextCronOccurrence( + ESPDate &date, const ScheduleSpec &spec, const DateTime &fromUtc, DateTime &outNextUtc +) { + DateTime cursor = roundToNextMinute(date, fromUtc); + for (int64_t minuteIndex = 0; minuteIndex < kMaxSearchMinutes; ++minuteIndex) { + const int month = date.getMonthLocal(cursor); + const int day = date.getDayLocal(cursor); + const int dayOfWeek = date.getWeekdayLocal(cursor); + + const DateTime startOfDay = date.startOfDayLocal(cursor); + const int64_t minutesIntoDay = date.differenceInMinutes(cursor, startOfDay); + if (minutesIntoDay < 0) { + cursor = date.addMinutes(cursor, 1); + continue; + } + + const int hour = static_cast(minutesIntoDay / 60); + const int minute = static_cast(minutesIntoDay % 60); + + const bool domAny = spec.dayOfMonth.isAny(); + const bool dowAny = spec.dayOfWeek.isAny(); + const bool domOk = spec.dayOfMonth.matches(day); + const bool dowOk = spec.dayOfWeek.matches(dayOfWeek); + + bool dayOk = false; + if (domAny && dowAny) { + dayOk = true; + } else if (domAny) { + dayOk = dowOk; + } else if (dowAny) { + dayOk = domOk; + } else { + dayOk = domOk || dowOk; + } + + if (spec.month.matches(month) && spec.hour.matches(hour) && spec.minute.matches(minute) && + dayOk) { + outNextUtc = date.setTimeOfDayLocal(cursor, hour, minute, 0); + return true; + } + cursor = date.addMinutes(cursor, 1); + } + return false; +} + +bool computeNextSunOccurrence( + ESPDate &date, + const ScheduleSpec &spec, + const DateTime &fromUtc, + DateTime &outNextUtc, + bool sunrise +) { + const DateTime rounded = roundToNextMinute(date, fromUtc); + const DateTime startOfDay = date.startOfDayLocal(rounded); + for (int64_t dayOffset = 0; dayOffset < kMaxSunSearchDays; ++dayOffset) { + const DateTime cursor = date.addDays(startOfDay, static_cast(dayOffset)); + const SunCycleResult cycle = sunrise ? date.sunrise(cursor) : date.sunset(cursor); + if (!cycle.ok) { + continue; + } + + const DateTime candidate = date.addMinutes(cycle.value, spec.sunOffsetMinutes); + if (date.isBefore(candidate, rounded)) { + continue; + } + outNextUtc = candidate; + return true; + } + return false; +} + +bool computeNextMoonPhaseOccurrence( + ESPDate &date, const ScheduleSpec &spec, const DateTime &fromUtc, DateTime &outNextUtc +) { + const DateTime rounded = roundToNextMinute(date, fromUtc); + DateTime previous = date.addMinutes(rounded, -1); + DateTime current = rounded; + + MoonPhaseResult previousPhase = date.moonPhase(previous); + if (!previousPhase.ok) { + return false; + } + double previousUnwrapped = static_cast(previousPhase.angleDegrees); + + for (int64_t minuteIndex = 0; minuteIndex < kMaxMoonSearchMinutes; ++minuteIndex) { + MoonPhaseResult currentPhase = date.moonPhase(current); + if (!currentPhase.ok) { + return false; + } + + const double currentUnwrapped = + unwrapAngle(previousUnwrapped, static_cast(currentPhase.angleDegrees)); + const bool crossed = + !valueWithinPeriodicWindow( + previousUnwrapped, + static_cast(spec.moonPhaseAngleDegrees), + static_cast(spec.moonPhaseToleranceDegrees) + ) && + segmentIntersectsPeriodicWindow( + previousUnwrapped, + currentUnwrapped, + static_cast(spec.moonPhaseAngleDegrees), + static_cast(spec.moonPhaseToleranceDegrees) + ); + if (crossed) { + outNextUtc = current; + return true; + } + + previousUnwrapped = currentUnwrapped; + current = date.addMinutes(current, 1); + } + return false; +} + +bool computeNextMoonIlluminationOccurrence( + ESPDate &date, const ScheduleSpec &spec, const DateTime &fromUtc, DateTime &outNextUtc +) { + const DateTime rounded = roundToNextMinute(date, fromUtc); + DateTime previous = date.addMinutes(rounded, -1); + DateTime current = rounded; + + MoonPhaseResult previousPhase = date.moonPhase(previous); + if (!previousPhase.ok) { + return false; + } + double previousIllumination = previousPhase.illumination * 100.0; + + for (int64_t minuteIndex = 0; minuteIndex < kMaxMoonSearchMinutes; ++minuteIndex) { + MoonPhaseResult currentPhase = date.moonPhase(current); + if (!currentPhase.ok) { + return false; + } + + const double currentIllumination = currentPhase.illumination * 100.0; + const double minWindow = + spec.moonIlluminationTargetPercent - spec.moonIlluminationTolerancePercent; + const double maxWindow = + spec.moonIlluminationTargetPercent + spec.moonIlluminationTolerancePercent; + const bool wasInside = previousIllumination >= minWindow - kComparisonEpsilon && + previousIllumination <= maxWindow + kComparisonEpsilon; + if (!wasInside && + segmentIntersectsRange(previousIllumination, currentIllumination, minWindow, maxWindow)) { + outNextUtc = current; + return true; + } + + previousIllumination = currentIllumination; + current = date.addMinutes(current, 1); + } + return false; +} +} // namespace + +bool ScheduleCalculator::validate(const ScheduleSpec &spec) { + switch (resolvedScheduleKind(spec)) { + case ScheduleKind::OneShotUtc: + return true; + case ScheduleKind::Cron: + return fieldWithinRange(spec.minute, 0, 59) && fieldWithinRange(spec.hour, 0, 23) && + fieldWithinRange(spec.dayOfMonth, 1, 31) && + fieldWithinRange(spec.month, 1, 12) && fieldWithinRange(spec.dayOfWeek, 0, 6); + case ScheduleKind::Sunrise: + case ScheduleKind::Sunset: + return spec.sunOffsetMinutes >= kMinSunOffsetMinutes && + spec.sunOffsetMinutes <= kMaxSunOffsetMinutes; + case ScheduleKind::MoonPhaseAngle: + return spec.moonPhaseAngleDegrees >= kMinMoonPhaseAngle && + spec.moonPhaseAngleDegrees <= kMaxMoonPhaseAngle && + spec.moonPhaseToleranceDegrees >= 0 && + spec.moonPhaseToleranceDegrees <= kMaxMoonPhaseTolerance; + case ScheduleKind::MoonIlluminationPercent: + return std::isfinite(spec.moonIlluminationTargetPercent) && + std::isfinite(spec.moonIlluminationTolerancePercent) && + spec.moonIlluminationTargetPercent >= kMinIlluminationPercent && + spec.moonIlluminationTargetPercent <= kMaxIlluminationPercent && + spec.moonIlluminationTolerancePercent > 0.0 && + spec.moonIlluminationTolerancePercent <= kMaxIlluminationTolerancePercent; + default: + return false; + } +} + +bool ScheduleCalculator::computeNext( + ESPDate &date, const ScheduleSpec &spec, const DateTime &fromUtc, DateTime &outNextUtc +) { + switch (resolvedScheduleKind(spec)) { + case ScheduleKind::OneShotUtc: + outNextUtc = spec.onceAtUtc; + return true; + case ScheduleKind::Cron: + return computeNextCronOccurrence(date, spec, fromUtc, outNextUtc); + case ScheduleKind::Sunrise: + return computeNextSunOccurrence(date, spec, fromUtc, outNextUtc, true); + case ScheduleKind::Sunset: + return computeNextSunOccurrence(date, spec, fromUtc, outNextUtc, false); + case ScheduleKind::MoonPhaseAngle: + return computeNextMoonPhaseOccurrence(date, spec, fromUtc, outNextUtc); + case ScheduleKind::MoonIlluminationPercent: + return computeNextMoonIlluminationOccurrence(date, spec, fromUtc, outNextUtc); + default: + return false; + } +} diff --git a/src/esp_scheduler/schedule/schedule_calculator.h b/src/esp_scheduler/schedule/schedule_calculator.h new file mode 100644 index 0000000..09eccd4 --- /dev/null +++ b/src/esp_scheduler/schedule/schedule_calculator.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include "schedule_spec.h" + +class ScheduleCalculator { + public: + static bool validate(const ScheduleSpec &spec); + static bool computeNext( + ESPDate &date, const ScheduleSpec &spec, const DateTime &fromUtc, DateTime &outNextUtc + ); +}; diff --git a/src/esp_scheduler/schedule/schedule_field.cpp b/src/esp_scheduler/schedule/schedule_field.cpp new file mode 100644 index 0000000..a3115d0 --- /dev/null +++ b/src/esp_scheduler/schedule/schedule_field.cpp @@ -0,0 +1,75 @@ +#include "schedule_field.h" + +ScheduleField ScheduleField::any() { + ScheduleField field; + field.isAny_ = true; + return field; +} + +ScheduleField ScheduleField::only(int value) { + ScheduleField field; + if (value < 0 || value > 63) { + return field; + } + field.mask_ = 1ULL << value; + return field; +} + +ScheduleField ScheduleField::range(int from, int to) { + ScheduleField field; + if (from < 0 || to < 0 || from > to || to > 63) { + return field; + } + for (int value = from; value <= to; ++value) { + field.mask_ |= 1ULL << value; + } + return field; +} + +ScheduleField ScheduleField::every(int step) { + ScheduleField field; + if (step <= 0) { + return field; + } + for (int value = 0; value <= 63; value += step) { + field.mask_ |= 1ULL << value; + } + return field; +} + +ScheduleField ScheduleField::rangeEvery(int from, int to, int step) { + ScheduleField field; + if (step <= 0 || from < 0 || to < 0 || from > to || to > 63) { + return field; + } + for (int value = from; value <= to; value += step) { + field.mask_ |= 1ULL << value; + } + return field; +} + +ScheduleField ScheduleField::list(const int *values, size_t count) { + ScheduleField field; + if (!values || count == 0) { + return field; + } + for (size_t index = 0; index < count; ++index) { + const int value = values[index]; + if (value < 0 || value > 63) { + field.mask_ = 0; + return field; + } + field.mask_ |= 1ULL << value; + } + return field; +} + +bool ScheduleField::matches(int value) const { + if (isAny_) { + return true; + } + if (value < 0 || value > 63) { + return false; + } + return (mask_ & (1ULL << value)) != 0; +} diff --git a/src/esp_scheduler/schedule/schedule_field.h b/src/esp_scheduler/schedule/schedule_field.h new file mode 100644 index 0000000..354ed2b --- /dev/null +++ b/src/esp_scheduler/schedule/schedule_field.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +class ScheduleField { + public: + static ScheduleField any(); + static ScheduleField only(int value); + static ScheduleField range(int from, int to); + static ScheduleField every(int step); + static ScheduleField rangeEvery(int from, int to, int step); + static ScheduleField list(const int *values, size_t count); + + bool matches(int value) const; + bool isAny() const { + return isAny_; + } + bool empty() const { + return !isAny_ && mask_ == 0; + } + uint64_t rawMask() const { + return mask_; + } + + private: + uint64_t mask_ = 0; + bool isAny_ = false; +}; diff --git a/src/esp_scheduler/schedule/schedule_spec.cpp b/src/esp_scheduler/schedule/schedule_spec.cpp new file mode 100644 index 0000000..45a23f0 --- /dev/null +++ b/src/esp_scheduler/schedule/schedule_spec.cpp @@ -0,0 +1,120 @@ +#include "schedule_spec.h" + +namespace { +int moonPhaseAngleForName(MoonPhaseName name) { + switch (name) { + case MoonPhaseName::NewMoon: + return 0; + case MoonPhaseName::WaxingCrescent: + return 45; + case MoonPhaseName::FirstQuarter: + return 90; + case MoonPhaseName::WaxingGibbous: + return 135; + case MoonPhaseName::FullMoon: + return 180; + case MoonPhaseName::WaningGibbous: + return 225; + case MoonPhaseName::LastQuarter: + return 270; + case MoonPhaseName::WaningCrescent: + return 315; + default: + return 0; + } +} +} // namespace + +ScheduleSpec ScheduleSpec::onceUtc(const DateTime &whenUtc) { + ScheduleSpec spec; + spec.kind = ScheduleKind::OneShotUtc; + spec.isOneShot = true; + spec.onceAtUtc = whenUtc; + return spec; +} + +ScheduleSpec ScheduleSpec::dailyAtLocal(int hour, int minute) { + ScheduleSpec spec; + spec.hour = ScheduleField::only(hour); + spec.minute = ScheduleField::only(minute); + return spec; +} + +ScheduleSpec ScheduleSpec::weeklyAtLocal(uint8_t dowMask, int hour, int minute) { + int days[7]; + size_t count = 0; + for (int bit = 0; bit < 7; ++bit) { + if ((dowMask & (1 << bit)) != 0) { + days[count++] = bit; + } + } + + ScheduleSpec spec; + spec.hour = ScheduleField::only(hour); + spec.minute = ScheduleField::only(minute); + spec.dayOfWeek = count == 0 ? ScheduleField::any() : ScheduleField::list(days, count); + return spec; +} + +ScheduleSpec ScheduleSpec::monthlyOnDayLocal(int dayOfMonth, int hour, int minute) { + ScheduleSpec spec; + if (dayOfMonth < 1) { + dayOfMonth = 1; + } else if (dayOfMonth > 31) { + dayOfMonth = 31; + } + spec.dayOfMonth = ScheduleField::only(dayOfMonth); + spec.hour = ScheduleField::only(hour); + spec.minute = ScheduleField::only(minute); + return spec; +} + +ScheduleSpec ScheduleSpec::sunrise(int offsetMinutes) { + ScheduleSpec spec; + spec.kind = ScheduleKind::Sunrise; + spec.sunOffsetMinutes = offsetMinutes; + return spec; +} + +ScheduleSpec ScheduleSpec::sunset(int offsetMinutes) { + ScheduleSpec spec; + spec.kind = ScheduleKind::Sunset; + spec.sunOffsetMinutes = offsetMinutes; + return spec; +} + +ScheduleSpec ScheduleSpec::moonPhaseAngle(int angleDegrees, int toleranceDegrees) { + ScheduleSpec spec; + spec.kind = ScheduleKind::MoonPhaseAngle; + spec.moonPhaseAngleDegrees = angleDegrees; + spec.moonPhaseToleranceDegrees = toleranceDegrees; + return spec; +} + +ScheduleSpec ScheduleSpec::moonPhase(MoonPhaseName name, int toleranceDegrees) { + return moonPhaseAngle(moonPhaseAngleForName(name), toleranceDegrees); +} + +ScheduleSpec ScheduleSpec::moonIlluminationPercent(double percent, double tolerancePercent) { + ScheduleSpec spec; + spec.kind = ScheduleKind::MoonIlluminationPercent; + spec.moonIlluminationTargetPercent = percent; + spec.moonIlluminationTolerancePercent = tolerancePercent; + return spec; +} + +ScheduleSpec ScheduleSpec::custom( + const ScheduleField &minute, + const ScheduleField &hour, + const ScheduleField &dom, + const ScheduleField &month, + const ScheduleField &dow +) { + ScheduleSpec spec; + spec.minute = minute; + spec.hour = hour; + spec.dayOfMonth = dom; + spec.month = month; + spec.dayOfWeek = dow; + return spec; +} diff --git a/src/esp_scheduler/schedule/schedule_spec.h b/src/esp_scheduler/schedule/schedule_spec.h new file mode 100644 index 0000000..115e739 --- /dev/null +++ b/src/esp_scheduler/schedule/schedule_spec.h @@ -0,0 +1,64 @@ +#pragma once + +#include + +#include "schedule_field.h" + +enum class ScheduleKind : uint8_t { + Cron = 0, + OneShotUtc, + Sunrise, + Sunset, + MoonPhaseAngle, + MoonIlluminationPercent, +}; + +enum class MoonPhaseName : uint8_t { + NewMoon = 0, + WaxingCrescent, + FirstQuarter, + WaxingGibbous, + FullMoon, + WaningGibbous, + LastQuarter, + WaningCrescent, +}; + +struct ScheduleSpec { + ScheduleKind kind = ScheduleKind::Cron; + bool isOneShot = false; + DateTime onceAtUtc{}; + + ScheduleField minute = ScheduleField::any(); + ScheduleField hour = ScheduleField::any(); + ScheduleField dayOfMonth = ScheduleField::any(); + ScheduleField month = ScheduleField::any(); + ScheduleField dayOfWeek = ScheduleField::any(); + + int sunOffsetMinutes = 0; + int moonPhaseAngleDegrees = 0; + int moonPhaseToleranceDegrees = 1; + double moonIlluminationTargetPercent = 0.0; + double moonIlluminationTolerancePercent = 0.5; + + static ScheduleSpec onceUtc(const DateTime &whenUtc); + static ScheduleSpec dailyAtLocal(int hour, int minute); + static ScheduleSpec weeklyAtLocal(uint8_t dowMask, int hour, int minute); + static ScheduleSpec monthlyOnDayLocal(int dayOfMonth, int hour, int minute); + static ScheduleSpec sunrise(int offsetMinutes = 0); + static ScheduleSpec sunset(int offsetMinutes = 0); + static ScheduleSpec moonPhaseAngle(int angleDegrees, int toleranceDegrees = 1); + static ScheduleSpec moonPhase(MoonPhaseName name, int toleranceDegrees = 1); + static ScheduleSpec moonIlluminationPercent( + double percent, double tolerancePercent = 0.5 + ); + static ScheduleSpec custom( + const ScheduleField &minute, + const ScheduleField &hour, + const ScheduleField &dom, + const ScheduleField &month, + const ScheduleField &dow + ); +}; + +using Schedule = ScheduleSpec; diff --git a/src/esp_scheduler/scheduler.cpp b/src/esp_scheduler/scheduler.cpp index ca3a26a..5c2a26d 100644 --- a/src/esp_scheduler/scheduler.cpp +++ b/src/esp_scheduler/scheduler.cpp @@ -1,1089 +1,594 @@ -#include "esp_scheduler/scheduler.h" +#include "scheduler.h" -#include -#include #include #include +#include "core/scheduler_core.h" +#include "core/runtime_containers.h" +#include "executors/dedicated_task_executor.h" +#include "executors/esp_worker_executor.h" +#include "executors/inline_executor.h" +#include "executors/worker_pool_executor.h" +#include "service/scheduler_commands.h" +#include "service/scheduler_service.h" + extern "C" { -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" +#include "freertos/queue.h" } namespace { -constexpr int64_t kMaxSearchMinutes = 366 * 24 * 60; -constexpr int64_t kMaxSunSearchDays = 732; -constexpr int64_t kMaxMoonSearchMinutes = 62 * 24 * 60; -constexpr int64_t kWorkerSleepChunkSeconds = 60; -constexpr int kMinSunOffsetMinutes = -1440; -constexpr int kMaxSunOffsetMinutes = 1440; -constexpr int kMinMoonPhaseAngle = 0; -constexpr int kMaxMoonPhaseAngle = 359; -constexpr int kMaxMoonPhaseTolerance = 30; -constexpr double kMinIlluminationPercent = 0.0; -constexpr double kMaxIlluminationPercent = 100.0; -constexpr double kMaxIlluminationTolerancePercent = 50.0; -constexpr double kFullCircleDegrees = 360.0; -constexpr double kComparisonEpsilon = 1e-9; - -bool clockValidForMin(const DateTime &nowUtc, int64_t minValidEpochSeconds) { - return nowUtc.epochSeconds >= minValidEpochSeconds; -} - -ScheduleKind resolvedScheduleKind(const Schedule &schedule) { - if (schedule.kind == ScheduleKind::Cron && schedule.isOneShot) { - return ScheduleKind::OneShotUtc; +template +TResult executeBackgroundCommand( + SchedulerService &service, + uint32_t timeoutMs, + SchedulerError queueError, + SchedulerError timeoutError, + FBuild &&build +) { + TCommand *command = new (std::nothrow) TCommand(); + if (!command) { + return TResult::failure(SchedulerError::NoMemory); } - return schedule.kind; -} - -bool isOneShotSchedule(const Schedule &schedule) { - return resolvedScheduleKind(schedule) == ScheduleKind::OneShotUtc; -} - -DateTime roundToNextMinute(const ESPDate &date, const DateTime &fromUtc) { - DateTime rounded = fromUtc; - if (fromUtc.secondUtc() > 0) { - rounded = date.addMinutes(rounded, 1); + build(*command); + if (!service.send(command)) { + delete command; + return TResult::failure(queueError); } - return date.setTimeOfDayUtc(rounded, rounded.hourUtc(), rounded.minuteUtc(), 0); -} - -double normalizeAngle360(double angle) { - double normalized = std::fmod(angle, kFullCircleDegrees); - if (normalized < 0.0) { - normalized += kFullCircleDegrees; + if (!command->wait(timeoutMs)) { + command->abandon(); + return TResult::failure(timeoutError); } - return normalized; + TResult result = command->result; + delete command; + return result; } +} // namespace -double unwrapAngle(double previousUnwrapped, double currentWrapped) { - const double previousWrapped = normalizeAngle360(previousUnwrapped); - double delta = currentWrapped - previousWrapped; - if (delta > 180.0) { - delta -= kFullCircleDegrees; - } else if (delta < -180.0) { - delta += kFullCircleDegrees; +struct ESPScheduler::Impl : public IExecutorResolver { + explicit Impl(ESPDate &date, const SchedulerConfig &config) + : date(date), + config(config), + manualCore(date, config.minValidEpochSeconds, config.usePSRAMMetadata), + externalExecutors(config.usePSRAMMetadata), + executors(config.usePSRAMMetadata) { } - return previousUnwrapped + delta; -} -bool valueWithinPeriodicWindow(double value, double center, double tolerance) { - const double distance = std::fabs(value - center); - double wrapped = std::fmod(distance, kFullCircleDegrees); - if (wrapped < 0.0) { - wrapped += kFullCircleDegrees; + ~Impl() { + if (eventQueue) { + vQueueDelete(eventQueue); + eventQueue = nullptr; + } } - const double minimumDistance = std::min(wrapped, kFullCircleDegrees - wrapped); - return minimumDistance <= tolerance + kComparisonEpsilon; -} -bool segmentIntersectsRange(double a, double b, double minValue, double maxValue) { - const double lo = std::min(a, b); - const double hi = std::max(a, b); - return hi >= minValue - kComparisonEpsilon && lo <= maxValue + kComparisonEpsilon; -} + ISchedulerExecutor *inlineExecutor() override { + return inlineDispatch.get(); + } -bool segmentIntersectsPeriodicWindow(double a, double b, double center, double tolerance) { - const double lo = std::min(a, b); - const double hi = std::max(a, b); - const int64_t firstPeriod = - static_cast(std::floor((lo - (center + tolerance)) / kFullCircleDegrees)) - 1; - const int64_t lastPeriod = - static_cast(std::ceil((hi - (center - tolerance)) / kFullCircleDegrees)) + 1; - for (int64_t period = firstPeriod; period <= lastPeriod; ++period) { - const double periodShift = static_cast(period) * kFullCircleDegrees; - const double windowMin = center - tolerance + periodShift; - const double windowMax = center + tolerance + periodShift; - if (segmentIntersectsRange(lo, hi, windowMin, windowMax)) { - return true; + ISchedulerExecutor *executorFor(uint8_t executorId) override { + if (executorId >= executors.size()) { + return nullptr; } + return executors[executorId]; } - return false; -} - -bool computeNextCronOccurrenceForDate( - const ESPDate &date, const Schedule &schedule, const DateTime &fromUtc, DateTime &outNextUtc -) { - const DateTime rounded = roundToNextMinute(date, fromUtc); - DateTime cursor = rounded; - for (int64_t i = 0; i < kMaxSearchMinutes; ++i) { - const int month = date.getMonthLocal(cursor); - const int day = date.getDayLocal(cursor); - const int dow = date.getWeekdayLocal(cursor); - const DateTime startOfDay = date.startOfDayLocal(cursor); - const int64_t minutesIntoDay = date.differenceInMinutes(cursor, startOfDay); - if (minutesIntoDay < 0) { - cursor = date.addMinutes(cursor, 1); - continue; + bool startExecutors() { + inlineDispatch.reset(new (std::nothrow) InlineExecutor()); + dedicatedTask.reset(new (std::nothrow) DedicatedTaskExecutor()); + if (!inlineDispatch || !dedicatedTask) { + return false; } - const int hour = static_cast(minutesIntoDay / 60); - const int minute = static_cast(minutesIntoDay % 60); - - const bool monthOk = schedule.month.matches(month); - const bool hourOk = schedule.hour.matches(hour); - const bool minuteOk = schedule.minute.matches(minute); - - const bool domAny = schedule.dayOfMonth.isAny(); - const bool dowAny = schedule.dayOfWeek.isAny(); - const bool domOk = schedule.dayOfMonth.matches(day); - const bool dowOk = schedule.dayOfWeek.matches(dow); - - bool dayOk = false; - if (domAny && dowAny) { - dayOk = true; - } else if (domAny && !dowAny) { - dayOk = dowOk; - } else if (!domAny && dowAny) { - dayOk = domOk; + if (config.defaultAsyncBackend == AsyncExecutorBackend::WorkerPool) { + workerPool.reset(new (std::nothrow) WorkerPoolExecutor(config.defaultWorkerPool)); + if (!workerPool) { + return false; + } } else { - dayOk = domOk || dowOk; + if (!config.espWorker) { + return false; + } + espWorkerAdapter.reset(new (std::nothrow) ESPWorkerExecutorAdapter(*config.espWorker)); + if (!espWorkerAdapter) { + return false; + } } - - if (monthOk && hourOk && minuteOk && dayOk) { - outNextUtc = date.setTimeOfDayLocal(cursor, hour, minute, 0); - return true; + if (!inlineDispatch->begin(runtime)) { + return false; } - cursor = date.addMinutes(cursor, 1); - } - return false; -} -bool computeNextSunOccurrenceForDate( - const ESPDate &date, - const Schedule &schedule, - const DateTime &fromUtc, - DateTime &outNextUtc, - bool sunrise -) { - const DateTime rounded = roundToNextMinute(date, fromUtc); - const DateTime startDay = date.startOfDayLocal(rounded); - for (int64_t dayOffset = 0; dayOffset < kMaxSunSearchDays; ++dayOffset) { - const DateTime dayCursor = date.addDays(startDay, static_cast(dayOffset)); - const SunCycleResult cycle = sunrise ? date.sunrise(dayCursor) : date.sunset(dayCursor); - if (!cycle.ok) { - continue; - } - const DateTime candidate = date.addMinutes(cycle.value, schedule.sunOffsetMinutes); - if (date.isBefore(candidate, rounded)) { - continue; + executors.clear(); + ISchedulerExecutor *asyncExecutor = + config.defaultAsyncBackend == AsyncExecutorBackend::ESPWorker + ? static_cast(espWorkerAdapter.get()) + : static_cast(workerPool.get()); + if (!executors.pushBack(asyncExecutor)) { + return false; } - outNextUtc = candidate; - return true; - } - return false; -} - -bool moonPhaseCrossed( - double previousUnwrapped, - double currentUnwrapped, - double targetAngleDegrees, - double toleranceDegrees -) { - if (valueWithinPeriodicWindow(previousUnwrapped, targetAngleDegrees, toleranceDegrees)) { - return false; - } - return segmentIntersectsPeriodicWindow( - previousUnwrapped, - currentUnwrapped, - targetAngleDegrees, - toleranceDegrees - ); -} - -bool moonIlluminationCrossed( - double previousValue, double currentValue, double targetPercent, double tolerancePercent -) { - const double minWindow = targetPercent - tolerancePercent; - const double maxWindow = targetPercent + tolerancePercent; - const bool wasInside = previousValue >= minWindow - kComparisonEpsilon && - previousValue <= maxWindow + kComparisonEpsilon; - if (wasInside) { - return false; - } - return segmentIntersectsRange(previousValue, currentValue, minWindow, maxWindow); -} - -bool computeNextMoonPhaseOccurrenceForDate( - const ESPDate &date, const Schedule &schedule, const DateTime &fromUtc, DateTime &outNextUtc -) { - const DateTime rounded = roundToNextMinute(date, fromUtc); - DateTime previous = date.addMinutes(rounded, -1); - DateTime current = rounded; - - MoonPhaseResult previousPhase = date.moonPhase(previous); - if (!previousPhase.ok) { - return false; - } - double previousUnwrapped = static_cast(previousPhase.angleDegrees); - - for (int64_t i = 0; i < kMaxMoonSearchMinutes; ++i) { - MoonPhaseResult currentPhase = date.moonPhase(current); - if (!currentPhase.ok) { + if (!executors.pushBack(dedicatedTask.get())) { return false; } - const double currentUnwrapped = - unwrapAngle(previousUnwrapped, static_cast(currentPhase.angleDegrees)); - if (moonPhaseCrossed( - previousUnwrapped, - currentUnwrapped, - static_cast(schedule.moonPhaseAngleDegrees), - static_cast(schedule.moonPhaseToleranceDegrees) - )) { - outNextUtc = current; - return true; + for (size_t index = 0; index < externalExecutors.size(); ++index) { + if (!executors.pushBack(externalExecutors[index])) { + return false; + } } - previousUnwrapped = currentUnwrapped; - current = date.addMinutes(current, 1); - } - return false; -} -bool computeNextMoonIlluminationOccurrenceForDate( - const ESPDate &date, const Schedule &schedule, const DateTime &fromUtc, DateTime &outNextUtc -) { - const DateTime rounded = roundToNextMinute(date, fromUtc); - DateTime previous = date.addMinutes(rounded, -1); - DateTime current = rounded; - - MoonPhaseResult previousPhase = date.moonPhase(previous); - if (!previousPhase.ok) { - return false; + for (size_t index = 0; index < executors.size(); ++index) { + ISchedulerExecutor *executor = executors[index]; + if (!executor || !executor->begin(runtime)) { + return false; + } + } + return true; } - double previousIllumination = previousPhase.illumination * kMaxIlluminationPercent; - for (int64_t i = 0; i < kMaxMoonSearchMinutes; ++i) { - MoonPhaseResult currentPhase = date.moonPhase(current); - if (!currentPhase.ok) { - return false; + void stopExecutors(bool drainRunningJobs) { + for (size_t index = 0; index < executors.size(); ++index) { + ISchedulerExecutor *executor = executors[index]; + if (executor) { + executor->end(drainRunningJobs); + } } - const double currentIllumination = currentPhase.illumination * kMaxIlluminationPercent; - if (moonIlluminationCrossed( - previousIllumination, - currentIllumination, - schedule.moonIlluminationTargetPercent, - schedule.moonIlluminationTolerancePercent - )) { - outNextUtc = current; - return true; + executors.clear(); + if (inlineDispatch) { + inlineDispatch->end(drainRunningJobs); } - previousIllumination = currentIllumination; - current = date.addMinutes(current, 1); + inlineDispatch.reset(); + espWorkerAdapter.reset(); + dedicatedTask.reset(); + workerPool.reset(); } - return false; -} - -int moonPhaseAngleForName(MoonPhaseName name) { - switch (name) { - case MoonPhaseName::NewMoon: - return 0; - case MoonPhaseName::WaxingCrescent: - return 45; - case MoonPhaseName::FirstQuarter: - return 90; - case MoonPhaseName::WaxingGibbous: - return 135; - case MoonPhaseName::FullMoon: - return 180; - case MoonPhaseName::WaningGibbous: - return 225; - case MoonPhaseName::LastQuarter: - return 270; - case MoonPhaseName::WaningCrescent: - return 315; - default: - return 0; - } -} - -bool computeNextOccurrenceForDate( - const ESPDate &date, const Schedule &schedule, const DateTime &fromUtc, DateTime &outNextUtc -) { - switch (resolvedScheduleKind(schedule)) { - case ScheduleKind::OneShotUtc: - outNextUtc = schedule.onceAtUtc; - return true; - case ScheduleKind::Cron: - return computeNextCronOccurrenceForDate(date, schedule, fromUtc, outNextUtc); - case ScheduleKind::Sunrise: - return computeNextSunOccurrenceForDate(date, schedule, fromUtc, outNextUtc, true); - case ScheduleKind::Sunset: - return computeNextSunOccurrenceForDate(date, schedule, fromUtc, outNextUtc, false); - case ScheduleKind::MoonPhaseAngle: - return computeNextMoonPhaseOccurrenceForDate(date, schedule, fromUtc, outNextUtc); - case ScheduleKind::MoonIlluminationPercent: - return computeNextMoonIlluminationOccurrenceForDate(date, schedule, fromUtc, outNextUtc); - default: - return false; - } -} -} // namespace - -ScheduleField ScheduleField::any() { - ScheduleField f; - f.m_isAny = true; - f.m_mask = 0; - return f; -} -ScheduleField ScheduleField::only(int value) { - ScheduleField f; - if (value < 0 || value > 63) { - return f; + void drainManualEvents(const DateTime &nowUtc) { + if (!eventQueue) { + return; + } + while (true) { + SchedulerEvent event{}; + if (xQueueReceive(eventQueue, &event, 0) != pdTRUE) { + break; + } + manualCore.handleEvent(event, nowUtc, *this); + } } - f.m_mask = 1ULL << value; - return f; -} -ScheduleField ScheduleField::range(int from, int to) { - ScheduleField f; - if (from < 0 || to < 0 || from > to || to > 63) { - return f; - } - for (int i = from; i <= to; ++i) { - f.m_mask |= 1ULL << i; - } - return f; -} + ESPDate &date; + SchedulerConfig config{}; + SchedulerCore manualCore; + std::unique_ptr service{}; + std::unique_ptr inlineDispatch{}; + std::unique_ptr workerPool{}; + std::unique_ptr espWorkerAdapter{}; + std::unique_ptr dedicatedTask{}; + SchedulerArray externalExecutors{}; + SchedulerArray executors{}; + std::shared_ptr runtime{}; + QueueHandle_t eventQueue = nullptr; + bool started = false; + bool draining = false; +}; -ScheduleField ScheduleField::every(int step) { - ScheduleField f; - if (step <= 0) { - return f; - } - for (int i = 0; i <= 63; i += step) { - f.m_mask |= 1ULL << i; - } - return f; +ESPScheduler::ESPScheduler(ESPDate &date, const SchedulerConfig &config) + : impl_(new (std::nothrow) Impl(date, config)) { } -ScheduleField ScheduleField::rangeEvery(int from, int to, int step) { - ScheduleField f; - if (step <= 0 || from < 0 || to < 0 || from > to || to > 63) { - return f; - } - for (int i = from; i <= to; i += step) { - f.m_mask |= 1ULL << i; - } - return f; +ESPScheduler::~ESPScheduler() { + end(true); } -ScheduleField ScheduleField::list(const int *values, size_t count) { - ScheduleField f; - if (!values || count == 0) { - return f; - } - for (size_t i = 0; i < count; ++i) { - const int v = values[i]; - if (v < 0 || v > 63) { - f.m_mask = 0; - return f; - } - f.m_mask |= 1ULL << v; +bool ESPScheduler::begin() { + if (!impl_) { + return false; } - return f; -} - -bool ScheduleField::matches(int value) const { - if (m_isAny) { + if (impl_->started) { return true; } - if (value < 0 || value > 63) { + + impl_->runtime = std::make_shared(); + if (!impl_->runtime) { return false; } - return (m_mask & (1ULL << value)) != 0; -} - -Schedule Schedule::onceUtc(const DateTime &whenUtc) { - Schedule s; - s.kind = ScheduleKind::OneShotUtc; - s.isOneShot = true; - s.onceAtUtc = whenUtc; - return s; -} - -Schedule Schedule::dailyAtLocal(int hour, int minute) { - Schedule s; - s.kind = ScheduleKind::Cron; - s.isOneShot = false; - s.hour = ScheduleField::only(hour); - s.minute = ScheduleField::only(minute); - s.dayOfMonth = ScheduleField::any(); - s.month = ScheduleField::any(); - s.dayOfWeek = ScheduleField::any(); - return s; -} -Schedule Schedule::weeklyAtLocal(uint8_t dowMask, int hour, int minute) { - int days[7]; - size_t count = 0; - for (int i = 0; i < 7; ++i) { - if (dowMask & (1 << i)) { - days[count++] = i; + if (impl_->config.mode == SchedulerMode::Background) { + impl_->service.reset(new (std::nothrow) SchedulerService( + impl_->date, + impl_->config.service, + impl_->config.minValidEpochSeconds, + impl_->config.usePSRAMMetadata, + *impl_ + )); + if (!impl_->service || !impl_->service->begin()) { + impl_->service.reset(); + impl_->runtime.reset(); + return false; } - } - Schedule s; - s.kind = ScheduleKind::Cron; - s.isOneShot = false; - s.hour = ScheduleField::only(hour); - s.minute = ScheduleField::only(minute); - s.dayOfMonth = ScheduleField::any(); - s.month = ScheduleField::any(); - if (count == 0) { - s.dayOfWeek = ScheduleField::any(); + impl_->runtime->eventQueue = impl_->service->eventQueue(); } else { - s.dayOfWeek = ScheduleField::list(days, count); + impl_->eventQueue = xQueueCreate( + impl_->config.service.eventQueueDepth, + sizeof(SchedulerEvent) + ); + if (!impl_->eventQueue) { + impl_->runtime.reset(); + return false; + } + impl_->runtime->eventQueue = impl_->eventQueue; } - return s; -} -Schedule Schedule::monthlyOnDayLocal(int dayOfMonth, int hour, int minute) { - Schedule s; - s.kind = ScheduleKind::Cron; - s.isOneShot = false; - int clamped = dayOfMonth; - if (clamped < 1) { - clamped = 1; - } else if (clamped > 31) { - clamped = 31; + impl_->started = true; + if (!impl_->startExecutors()) { + end(false); + return false; } - s.dayOfMonth = ScheduleField::only(clamped); - s.hour = ScheduleField::only(hour); - s.minute = ScheduleField::only(minute); - s.month = ScheduleField::any(); - s.dayOfWeek = ScheduleField::any(); - return s; -} - -Schedule Schedule::sunrise(int offsetMinutes) { - Schedule s; - s.kind = ScheduleKind::Sunrise; - s.isOneShot = false; - s.sunOffsetMinutes = offsetMinutes; - return s; -} - -Schedule Schedule::sunset(int offsetMinutes) { - Schedule s; - s.kind = ScheduleKind::Sunset; - s.isOneShot = false; - s.sunOffsetMinutes = offsetMinutes; - return s; -} -Schedule Schedule::moonPhaseAngle(int angleDegrees, int toleranceDegrees) { - Schedule s; - s.kind = ScheduleKind::MoonPhaseAngle; - s.isOneShot = false; - s.moonPhaseAngleDegrees = angleDegrees; - s.moonPhaseToleranceDegrees = toleranceDegrees; - return s; + impl_->draining = false; + return true; } -Schedule Schedule::moonPhase(MoonPhaseName name, int toleranceDegrees) { - return moonPhaseAngle(moonPhaseAngleForName(name), toleranceDegrees); -} - -Schedule Schedule::moonIlluminationPercent(double percent, double tolerancePercent) { - Schedule s; - s.kind = ScheduleKind::MoonIlluminationPercent; - s.isOneShot = false; - s.moonIlluminationTargetPercent = percent; - s.moonIlluminationTolerancePercent = tolerancePercent; - return s; -} - -Schedule Schedule::custom( - const ScheduleField &minute, - const ScheduleField &hour, - const ScheduleField &dom, - const ScheduleField &month, - const ScheduleField &dow -) { - Schedule s; - s.kind = ScheduleKind::Cron; - s.isOneShot = false; - s.minute = minute; - s.hour = hour; - s.dayOfMonth = dom; - s.month = month; - s.dayOfWeek = dow; - return s; -} - -ESPScheduler::ESPScheduler(ESPDate &date, ESPWorker *worker) - : ESPScheduler(date, worker, ESPSchedulerConfig{}) { -} - -ESPScheduler::ESPScheduler(ESPDate &date, const ESPSchedulerConfig &config) - : ESPScheduler(date, nullptr, config) { -} - -ESPScheduler::ESPScheduler(ESPDate &date, ESPWorker *worker, const ESPSchedulerConfig &config) - : m_date(date), m_minValidEpochSecondsRef( - std::make_shared>(kDefaultMinValidEpochSeconds) - ), - usePSRAMBuffers_(config.usePSRAMBuffers), - m_inlineJobs(SchedulerAllocator(usePSRAMBuffers_)), - m_workerJobs(SchedulerAllocator(usePSRAMBuffers_)) { - (void)worker; -} - -ESPScheduler::~ESPScheduler() { - deinit(); -} - -void ESPScheduler::deinit() { - if (!m_initialized.exchange(false, std::memory_order_relaxed)) { +void ESPScheduler::end(bool waitForRunningJobs, uint32_t timeoutMs) { + if (!impl_ || !impl_->started) { return; } - for (auto &job : m_inlineJobs) { - job.finished = true; - } - for (auto &job : m_workerJobs) { - if (job.context) { - job.context->cancelRequested.store(true); - } - } - cleanupInline(); - m_workerJobs.clear(); + impl_->draining = true; - SchedulerVector(SchedulerAllocator(usePSRAMBuffers_)).swap(m_inlineJobs); - SchedulerVector(SchedulerAllocator(usePSRAMBuffers_)).swap(m_workerJobs); - m_nextId = 1; -} + if (impl_->config.mode == SchedulerMode::Background && impl_->service) { + executeBackgroundCommand>( + *impl_->service, + impl_->config.service.controlTimeoutMs, + SchedulerError::QueueFull, + SchedulerError::Timeout, + [](CancelAllCommand &) {} + ); -bool ESPScheduler::isInitialized() const { - return m_initialized.load(std::memory_order_relaxed); -} + if (waitForRunningJobs) { + const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeoutMs); + while (impl_->service->activeInvocationCount() > 0 && + xTaskGetTickCount() < deadline) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + } -void ESPScheduler::ensureInitialized() { - if (!isInitialized()) { - m_initialized.store(true, std::memory_order_relaxed); + if (impl_->runtime) { + impl_->runtime->accepting.store(waitForRunningJobs); + if (!waitForRunningJobs) { + impl_->runtime->eventQueue = nullptr; + impl_->runtime->accepting.store(false); + } + } + impl_->stopExecutors(waitForRunningJobs); + impl_->service->stop(); + impl_->service.reset(); + } else { + impl_->manualCore.cancelAll(); + const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeoutMs); + while (waitForRunningJobs && impl_->manualCore.activeInvocationCount() > 0 && + xTaskGetTickCount() < deadline) { + impl_->drainManualEvents(impl_->date.now()); + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (impl_->runtime) { + if (!waitForRunningJobs) { + impl_->runtime->eventQueue = nullptr; + impl_->runtime->accepting.store(false); + } + } + impl_->stopExecutors(waitForRunningJobs); + if (impl_->eventQueue) { + vQueueDelete(impl_->eventQueue); + impl_->eventQueue = nullptr; + } } -} -void ESPScheduler::setMinValidUnixSeconds(int64_t minEpochSeconds) { - m_minValidEpochSeconds = minEpochSeconds; - if (m_minValidEpochSecondsRef) { - m_minValidEpochSecondsRef->store(minEpochSeconds); + if (impl_->runtime) { + impl_->runtime->eventQueue = nullptr; + impl_->runtime->accepting.store(false); + impl_->runtime.reset(); } + impl_->started = false; + impl_->draining = false; } -void ESPScheduler::setMinValidUtc(const DateTime &minUtc) { - setMinValidUnixSeconds(minUtc.epochSeconds); +bool ESPScheduler::running() const { + return impl_ && impl_->started && !impl_->draining; } -int64_t ESPScheduler::minValidUnixSeconds() const { - return m_minValidEpochSeconds; +bool ESPScheduler::draining() const { + return impl_ && impl_->draining; } -uint32_t ESPScheduler::nextId() { - if (m_nextId == 0) { - m_nextId = 1; +SchedulerResult ESPScheduler::registerExecutor(ISchedulerExecutor *executor) { + if (!impl_ || !executor) { + return SchedulerResult::failure(SchedulerError::ExecutorUnavailable); + } + if (impl_->started) { + return SchedulerResult::failure(SchedulerError::Busy); } - return m_nextId++; + const uint8_t executorId = static_cast(2 + impl_->externalExecutors.size()); + if (!impl_->externalExecutors.pushBack(executor)) { + return SchedulerResult::failure(SchedulerError::NoMemory); + } + return SchedulerResult::success(executorId); } -bool ESPScheduler::fieldWithinRange(const ScheduleField &field, int min, int max) const { - if (field.isAny()) { - return true; +SchedulerResult ESPScheduler::addJob( + const ScheduleSpec &schedule, + const JobOptions &options, + SchedulerCallbackFn callback, + void *userData +) { + if (!callback) { + return SchedulerResult::failure(SchedulerError::InvalidSchedule); } - const uint64_t mask = field.rawMask(); - const uint64_t allowed = allowedMask(min, max); - return mask != 0 && (mask & allowed) != 0; + CallbackRef ref{}; + ref.kind = CallbackKind::RawFunction; + ref.rawFn = callback; + ref.userData = userData; + return addJobImpl(schedule, options, ref); } -uint64_t ESPScheduler::allowedMask(int min, int max) const { - if (min < 0) { - min = 0; - } - if (max > 63) { - max = 63; +SchedulerResult ESPScheduler::addJob( + const ScheduleSpec &schedule, + const JobOptions &options, + SchedulerFunction callback, + void *userData +) { + if (!callback) { + return SchedulerResult::failure(SchedulerError::InvalidSchedule); } - if (max >= 63) { - return ~static_cast(0); + CallbackRef ref{}; + ref.kind = CallbackKind::OwningFunction; + ref.userData = userData; + ref.owningFn = std::make_shared(std::move(callback)); + if (!ref.owningFn) { + return SchedulerResult::failure(SchedulerError::NoMemory); } - const uint64_t upper = (1ULL << (max + 1)) - 1; - const uint64_t lower = (min == 0) ? 0 : ((1ULL << min) - 1); - return upper & ~lower; + return addJobImpl(schedule, options, ref); } -bool ESPScheduler::validateSchedule(const Schedule &schedule) const { - const ScheduleKind kind = resolvedScheduleKind(schedule); - switch (kind) { - case ScheduleKind::OneShotUtc: - return true; - case ScheduleKind::Cron: { - const bool minuteOk = fieldWithinRange(schedule.minute, 0, 59); - const bool hourOk = fieldWithinRange(schedule.hour, 0, 23); - const bool domOk = fieldWithinRange(schedule.dayOfMonth, 1, 31); - const bool monthOk = fieldWithinRange(schedule.month, 1, 12); - const bool dowOk = fieldWithinRange(schedule.dayOfWeek, 0, 6); - return minuteOk && hourOk && domOk && monthOk && dowOk; +SchedulerResult ESPScheduler::addJob( + const ScheduleSpec &schedule, const JobOptions &options, SchedulerFunctionNoData callback +) { + if (!callback) { + return SchedulerResult::failure(SchedulerError::InvalidSchedule); } - case ScheduleKind::Sunrise: - case ScheduleKind::Sunset: - return schedule.sunOffsetMinutes >= kMinSunOffsetMinutes && - schedule.sunOffsetMinutes <= kMaxSunOffsetMinutes; - case ScheduleKind::MoonPhaseAngle: - return schedule.moonPhaseAngleDegrees >= kMinMoonPhaseAngle && - schedule.moonPhaseAngleDegrees <= kMaxMoonPhaseAngle && - schedule.moonPhaseToleranceDegrees >= 0 && - schedule.moonPhaseToleranceDegrees <= kMaxMoonPhaseTolerance; - case ScheduleKind::MoonIlluminationPercent: - return std::isfinite(schedule.moonIlluminationTargetPercent) && - std::isfinite(schedule.moonIlluminationTolerancePercent) && - schedule.moonIlluminationTargetPercent >= kMinIlluminationPercent && - schedule.moonIlluminationTargetPercent <= kMaxIlluminationPercent && - schedule.moonIlluminationTolerancePercent > 0.0 && - schedule.moonIlluminationTolerancePercent <= kMaxIlluminationTolerancePercent; - default: - return false; + SchedulerFunction wrapped = [fn = std::move(callback)](void *) { fn(); }; + CallbackRef ref{}; + ref.kind = CallbackKind::OwningFunction; + ref.owningFn = std::make_shared(std::move(wrapped)); + if (!ref.owningFn) { + return SchedulerResult::failure(SchedulerError::NoMemory); } + return addJobImpl(schedule, options, ref); } -uint32_t ESPScheduler::addJobOnceUtc( +SchedulerResult ESPScheduler::addJobOnceUtc( const DateTime &whenUtc, - SchedulerJobMode mode, - SchedulerCallback cb, - void *userData, - const SchedulerTaskConfig *taskCfg + const JobOptions &options, + SchedulerCallbackFn callback, + void *userData ) { - return addJobOnceUtc(whenUtc, mode, SchedulerFunction(cb), userData, taskCfg); + return addJob(ScheduleSpec::onceUtc(whenUtc), options, callback, userData); } -uint32_t ESPScheduler::addJobOnceUtc( +SchedulerResult ESPScheduler::addJobOnceUtc( const DateTime &whenUtc, - SchedulerJobMode mode, - SchedulerFunction cb, - void *userData, - const SchedulerTaskConfig *taskCfg + const JobOptions &options, + SchedulerFunction callback, + void *userData ) { - Schedule s = Schedule::onceUtc(whenUtc); - return addJob(s, mode, std::move(cb), userData, taskCfg); + return addJob(ScheduleSpec::onceUtc(whenUtc), options, std::move(callback), userData); } -uint32_t ESPScheduler::addJobOnceUtc( - const DateTime &whenUtc, - SchedulerJobMode mode, - SchedulerFunctionNoData cb, - const SchedulerTaskConfig *taskCfg +SchedulerResult ESPScheduler::addJobOnceUtc( + const DateTime &whenUtc, const JobOptions &options, SchedulerFunctionNoData callback ) { - if (!cb) { - return 0; - } - SchedulerFunction wrapped = [fn = std::move(cb)](void *) { fn(); }; - return addJobOnceUtc(whenUtc, mode, std::move(wrapped), nullptr, taskCfg); + return addJob(ScheduleSpec::onceUtc(whenUtc), options, std::move(callback)); } -uint32_t ESPScheduler::addJob( - const Schedule &schedule, - SchedulerJobMode mode, - SchedulerCallback cb, - void *userData, - const SchedulerTaskConfig *taskCfg +SchedulerResult ESPScheduler::addJobImpl( + const ScheduleSpec &schedule, const JobOptions &options, const CallbackRef &callback ) { - return addJob(schedule, mode, SchedulerFunction(cb), userData, taskCfg); -} - -uint32_t ESPScheduler::addJob( - const Schedule &schedule, - SchedulerJobMode mode, - SchedulerFunction cb, - void *userData, - const SchedulerTaskConfig *taskCfg -) { - if (!cb) { - return 0; - } - if (!validateSchedule(schedule)) { - return 0; + if (!impl_ || !impl_->started || impl_->draining) { + return SchedulerResult::failure(SchedulerError::NotInitialized); } - ensureInitialized(); - const uint32_t id = nextId(); - - if (mode == SchedulerJobMode::Inline) { - InlineJob job{}; - job.id = id; - job.schedule = schedule; - job.callback = std::move(cb); - job.userData = userData; - m_inlineJobs.push_back(job); - return id; + if (options.dispatch == DispatchPolicy::Async && impl_->executorFor(options.executorId) == nullptr) { + return SchedulerResult::failure(SchedulerError::ExecutorUnavailable); } - auto ctx = std::allocate_shared( - SchedulerAllocator(usePSRAMBuffers_) - ); - ctx->schedule = schedule; - ctx->callback = std::move(cb); - ctx->userData = userData; - ctx->date = &m_date; - ctx->minValidEpochSeconds = m_minValidEpochSecondsRef; - - const SchedulerTaskConfig runtimeCfg = makeTaskConfig(taskCfg); - auto *taskCtx = new (std::nothrow) std::shared_ptr(ctx); - if (!taskCtx) { - return 0; + if (impl_->config.mode == SchedulerMode::Background && impl_->service) { + return executeBackgroundCommand>( + *impl_->service, + impl_->config.service.controlTimeoutMs, + SchedulerError::QueueFull, + SchedulerError::Timeout, + [&](AddJobCommand &command) { + command.schedule = schedule; + command.options = options; + if (options.dedicatedTask) { + command.dedicatedTaskCopy = *options.dedicatedTask; + command.options.dedicatedTask = &command.dedicatedTaskCopy; + } + command.callback = callback; + } + ); } - TaskHandle_t taskHandle = nullptr; - const BaseType_t created = xTaskCreatePinnedToCore( - &ESPScheduler::workerTaskEntry, - runtimeCfg.name ? runtimeCfg.name : "sched-job", - runtimeCfg.stackSize, - taskCtx, - runtimeCfg.priority, - &taskHandle, - runtimeCfg.coreId - ); - if (created != pdPASS || taskHandle == nullptr) { - delete taskCtx; - return 0; - } - - WorkerJob job{}; - job.id = id; - job.context = ctx; - job.task = taskHandle; - m_workerJobs.push_back(job); - return id; -} -uint32_t ESPScheduler::addJob( - const Schedule &schedule, - SchedulerJobMode mode, - SchedulerFunctionNoData cb, - const SchedulerTaskConfig *taskCfg -) { - if (!cb) { - return 0; - } - SchedulerFunction wrapped = [fn = std::move(cb)](void *) { fn(); }; - return addJob(schedule, mode, std::move(wrapped), nullptr, taskCfg); + return impl_->manualCore.addJob(schedule, options, callback, impl_->date.now()); } -bool ESPScheduler::cancelJob(uint32_t jobId) { - if (!isInitialized()) { - return false; - } - - bool canceled = false; - for (auto &job : m_inlineJobs) { - if (job.id == jobId && !job.finished) { - job.finished = true; - canceled = true; - } - } - for (auto &job : m_workerJobs) { - if (job.id == jobId && job.context) { - job.context->cancelRequested.store(true); - canceled = true; - } +SchedulerResult ESPScheduler::cancelJob(uint32_t jobId) { + if (!impl_ || !impl_->started || impl_->draining) { + return SchedulerResult::failure(SchedulerError::NotInitialized); } - if (canceled) { - cleanupInline(); - cleanupWorkers(); + if (impl_->config.mode == SchedulerMode::Background && impl_->service) { + return executeBackgroundCommand>( + *impl_->service, + impl_->config.service.controlTimeoutMs, + SchedulerError::QueueFull, + SchedulerError::Timeout, + [&](CancelJobCommand &command) { command.jobId = jobId; } + ); } - return canceled; + return impl_->manualCore.cancelJob(jobId); } -bool ESPScheduler::pauseJob(uint32_t jobId) { - if (!isInitialized()) { - return false; +SchedulerResult ESPScheduler::pauseJob(uint32_t jobId) { + if (!impl_ || !impl_->started || impl_->draining) { + return SchedulerResult::failure(SchedulerError::NotInitialized); } - - for (auto &job : m_inlineJobs) { - if (job.id == jobId && !job.finished) { - job.paused = true; - return true; - } - } - for (auto &job : m_workerJobs) { - if (job.id == jobId && job.context) { - job.context->paused.store(true); - return true; - } + if (impl_->config.mode == SchedulerMode::Background && impl_->service) { + return executeBackgroundCommand>( + *impl_->service, + impl_->config.service.controlTimeoutMs, + SchedulerError::QueueFull, + SchedulerError::Timeout, + [&](PauseJobCommand &command) { command.jobId = jobId; } + ); } - return false; + return impl_->manualCore.pauseJob(jobId); } -bool ESPScheduler::resumeJob(uint32_t jobId) { - if (!isInitialized()) { - return false; +SchedulerResult ESPScheduler::resumeJob(uint32_t jobId) { + if (!impl_ || !impl_->started || impl_->draining) { + return SchedulerResult::failure(SchedulerError::NotInitialized); } - - for (auto &job : m_inlineJobs) { - if (job.id == jobId && !job.finished) { - job.paused = false; - return true; - } + if (impl_->config.mode == SchedulerMode::Background && impl_->service) { + return executeBackgroundCommand>( + *impl_->service, + impl_->config.service.controlTimeoutMs, + SchedulerError::QueueFull, + SchedulerError::Timeout, + [&](ResumeJobCommand &command) { command.jobId = jobId; } + ); } - for (auto &job : m_workerJobs) { - if (job.id == jobId && job.context) { - job.context->paused.store(false); - return true; - } - } - return false; + return impl_->manualCore.resumeJob(jobId, impl_->date.now()); } -void ESPScheduler::cancelAll() { - if (!isInitialized()) { - return; +SchedulerResult ESPScheduler::cancelAll() { + if (!impl_ || !impl_->started || impl_->draining) { + return SchedulerResult::failure(SchedulerError::NotInitialized); } - - for (auto &job : m_inlineJobs) { - job.finished = true; + if (impl_->config.mode == SchedulerMode::Background && impl_->service) { + return executeBackgroundCommand>( + *impl_->service, + impl_->config.service.controlTimeoutMs, + SchedulerError::QueueFull, + SchedulerError::Timeout, + [](CancelAllCommand &) {} + ); } - for (auto &job : m_workerJobs) { - if (job.context) { - job.context->cancelRequested.store(true); - } - } - cleanupInline(); - m_workerJobs.clear(); + return impl_->manualCore.cancelAll(); } void ESPScheduler::tick() { - tick(m_date.now()); + if (!impl_) { + return; + } + tick(impl_->date.now()); } void ESPScheduler::tick(const DateTime &nowUtc) { - if (!isInitialized()) { + if (!impl_ || !impl_->started || impl_->config.mode == SchedulerMode::Background) { return; } + impl_->drainManualEvents(nowUtc); + impl_->manualCore.dispatchDue(nowUtc, *impl_); + impl_->drainManualEvents(nowUtc); +} - if (!clockValid(nowUtc)) { - return; +SchedulerResult ESPScheduler::jobCount() const { + if (!impl_ || !impl_->started) { + return SchedulerResult::failure(SchedulerError::NotInitialized); } - - for (auto &job : m_inlineJobs) { - if (job.finished || job.paused) { - continue; - } - if (!job.hasNext) { - if (isOneShotSchedule(job.schedule)) { - job.nextRunUtc = job.schedule.onceAtUtc; - job.hasNext = true; - } else { - job.hasNext = computeNextOccurrence(job.schedule, nowUtc, job.nextRunUtc); - if (!job.hasNext) { - job.finished = true; - continue; - } - } - } - - if (m_date.isAfter(job.nextRunUtc, nowUtc)) { - continue; - } - job.callback(job.userData); - if (isOneShotSchedule(job.schedule)) { - job.finished = true; - continue; - } - DateTime from = m_date.addMinutes(job.nextRunUtc, 1); - job.hasNext = computeNextOccurrence(job.schedule, from, job.nextRunUtc); - if (!job.hasNext) { - job.finished = true; - } + if (impl_->config.mode == SchedulerMode::Background && impl_->service) { + return executeBackgroundCommand>( + *impl_->service, + impl_->config.service.controlTimeoutMs, + SchedulerError::QueueFull, + SchedulerError::Timeout, + [](JobCountCommand &) {} + ); } - - cleanupInline(); - cleanupWorkers(); + return impl_->manualCore.jobCount(); } -void ESPScheduler::cleanup() { - if (!isInitialized()) { - return; +SchedulerResult ESPScheduler::getJobInfo(uint32_t jobId, JobInfo &out) const { + if (!impl_ || !impl_->started) { + out = JobInfo{}; + return SchedulerResult::failure(SchedulerError::NotInitialized); } - - cleanupInline(); - cleanupWorkers(); + if (impl_->config.mode == SchedulerMode::Background && impl_->service) { + return executeBackgroundCommand>( + *impl_->service, + impl_->config.service.controlTimeoutMs, + SchedulerError::QueueFull, + SchedulerError::Timeout, + [&](GetJobInfoCommand &command) { + command.jobId = jobId; + command.info = &out; + } + ); + } + return impl_->manualCore.getJobInfo(jobId, out); } -bool ESPScheduler::getJobInfo(size_t index, JobInfo &out) const { - if (!isInitialized()) { - out = JobInfo{}; - return false; +void ESPScheduler::setMinValidUnixSeconds(int64_t minEpochSeconds) { + if (!impl_) { + return; } - - out = JobInfo{}; - size_t current = 0; - auto fillNext = [this]( - const Schedule &schedule, - bool hasNext, - const DateTime &storedNext, - DateTime &outNext - ) { - if (hasNext) { - outNext = storedNext; - return; - } - if (isOneShotSchedule(schedule)) { - outNext = schedule.onceAtUtc; - return; - } - DateTime computed{}; - if (computeNextOccurrence(schedule, m_date.now(), computed)) { - outNext = computed; - } else { - outNext = {}; - } - }; - - for (const auto &job : m_inlineJobs) { - if (job.finished) { - continue; - } - if (current == index) { - out.id = job.id; - out.enabled = !job.paused; - out.mode = SchedulerJobMode::Inline; - out.schedule = job.schedule; - fillNext(job.schedule, job.hasNext, job.nextRunUtc, out.nextRunUtc); - return true; - } - ++current; + impl_->config.minValidEpochSeconds = minEpochSeconds; + if (!impl_->started) { + impl_->manualCore.setMinValidUnixSeconds(minEpochSeconds); + return; } - - for (const auto &job : m_workerJobs) { - if (!job.context) { - continue; - } - if (job.context->cancelRequested.load() || job.context->finished.load()) { - continue; - } - if (current == index) { - out.id = job.id; - out.enabled = !job.context->paused.load(); - out.mode = SchedulerJobMode::WorkerTask; - out.schedule = job.context->schedule; - fillNext( - job.context->schedule, - job.context->hasNext, - job.context->nextRunUtc, - out.nextRunUtc - ); - return true; - } - ++current; + if (impl_->config.mode == SchedulerMode::Background && impl_->service) { + (void)executeBackgroundCommand>( + *impl_->service, + impl_->config.service.controlTimeoutMs, + SchedulerError::QueueFull, + SchedulerError::Timeout, + [&](SetMinValidCommand &command) { command.minEpochSeconds = minEpochSeconds; } + ); + return; } - - return false; + impl_->manualCore.setMinValidUnixSeconds(minEpochSeconds); } -bool ESPScheduler::computeNextOccurrence( - const Schedule &schedule, const DateTime &fromUtc, DateTime &outNextUtc -) const { - return computeNextOccurrenceForDate(m_date, schedule, fromUtc, outNextUtc); +void ESPScheduler::setMinValidUtc(const DateTime &minUtc) { + setMinValidUnixSeconds(minUtc.epochSeconds); } -void ESPScheduler::runWorkerJob(const std::shared_ptr &ctx) { - if (!ctx || !ctx->date) { - return; - } - - ESPDate &date = *ctx->date; - while (!ctx->cancelRequested.load()) { - DateTime now = date.now(); - const int64_t minValidEpochSeconds = ctx->minValidEpochSeconds - ? ctx->minValidEpochSeconds->load() - : kDefaultMinValidEpochSeconds; - if (!clockValidForMin(now, minValidEpochSeconds)) { - vTaskDelay(pdMS_TO_TICKS(kWorkerSleepChunkSeconds * 1000)); - continue; - } - if (!ctx->hasNext) { - if (isOneShotSchedule(ctx->schedule)) { - ctx->nextRunUtc = ctx->schedule.onceAtUtc; - ctx->hasNext = true; - } else { - ctx->hasNext = - computeNextOccurrenceForDate(date, ctx->schedule, now, ctx->nextRunUtc); - if (!ctx->hasNext) { - break; - } - } - } - - if (ctx->paused.load()) { - vTaskDelay(pdMS_TO_TICKS(kWorkerSleepChunkSeconds * 1000)); - continue; - } - - const int64_t diffSec = date.differenceInSeconds(ctx->nextRunUtc, now); - if (diffSec > 0) { - const int64_t chunk = - (diffSec > kWorkerSleepChunkSeconds) ? kWorkerSleepChunkSeconds : diffSec; - vTaskDelay(pdMS_TO_TICKS(static_cast(chunk * 1000))); - continue; - } - - ctx->callback(ctx->userData); - - if (isOneShotSchedule(ctx->schedule)) { - break; - } - DateTime from = date.addMinutes(ctx->nextRunUtc, 1); - ctx->hasNext = computeNextOccurrenceForDate(date, ctx->schedule, from, ctx->nextRunUtc); - if (!ctx->hasNext) { - break; - } +int64_t ESPScheduler::minValidUnixSeconds() const { + if (!impl_) { + return kDefaultMinValidEpochSeconds; } - ctx->finished.store(true); + return impl_->config.minValidEpochSeconds; } -bool ESPScheduler::clockValid(const DateTime &nowUtc) const { - return clockValidForMin(nowUtc, m_minValidEpochSeconds); -} - -SchedulerTaskConfig ESPScheduler::makeTaskConfig(const SchedulerTaskConfig *taskCfg) const { - SchedulerTaskConfig cfg{}; - cfg.stackSize = taskCfg ? taskCfg->stackSize : SchedulerTaskConfig{}.stackSize; - cfg.priority = taskCfg ? taskCfg->priority : SchedulerTaskConfig{}.priority; - cfg.coreId = taskCfg ? taskCfg->coreId : SchedulerTaskConfig{}.coreId; - cfg.usePsramStack = taskCfg ? taskCfg->usePsramStack : SchedulerTaskConfig{}.usePsramStack; - cfg.name = taskCfg && taskCfg->name ? taskCfg->name : "sched-job"; - return cfg; +uint8_t ESPScheduler::defaultWorkerExecutor() const { + if (!impl_ || impl_->config.defaultAsyncBackend != AsyncExecutorBackend::WorkerPool) { + return kInvalidExecutorId; + } + return 0; } -void ESPScheduler::workerTaskEntry(void *arg) { - auto *ctxPtr = static_cast *>(arg); - if (!ctxPtr) { - vTaskDelete(nullptr); - return; +uint8_t ESPScheduler::defaultESPWorkerExecutor() const { + if (!impl_ || impl_->config.defaultAsyncBackend != AsyncExecutorBackend::ESPWorker || + impl_->config.espWorker == nullptr) { + return kInvalidExecutorId; } - std::shared_ptr ctx = *ctxPtr; - delete ctxPtr; - runWorkerJob(ctx); - vTaskDelete(nullptr); + return 0; } -void ESPScheduler::cleanupInline() { - m_inlineJobs.erase( - std::remove_if( - m_inlineJobs.begin(), - m_inlineJobs.end(), - [](const InlineJob &job) { return job.finished; } - ), - m_inlineJobs.end() - ); +uint8_t ESPScheduler::defaultDedicatedExecutor() const { + return 1; } -void ESPScheduler::cleanupWorkers() { - m_workerJobs.erase( - std::remove_if( - m_workerJobs.begin(), - m_workerJobs.end(), - [](const WorkerJob &job) { - return !job.context || job.context->finished.load() || - job.context->cancelRequested.load(); - } - ), - m_workerJobs.end() - ); +bool ESPScheduler::computeNextOccurrence( + const ScheduleSpec &schedule, const DateTime &fromUtc, DateTime &outNextUtc +) const { + if (!impl_) { + return false; + } + return ScheduleCalculator::computeNext(impl_->date, schedule, fromUtc, outNextUtc); } diff --git a/src/esp_scheduler/scheduler.h b/src/esp_scheduler/scheduler.h index b62e658..ca31519 100644 --- a/src/esp_scheduler/scheduler.h +++ b/src/esp_scheduler/scheduler.h @@ -3,252 +3,129 @@ #include #include -#include #include #include +#include extern "C" { #include "freertos/FreeRTOS.h" #include "freertos/task.h" } -#include "scheduler_allocator.h" +#include "schedule/schedule_calculator.h" +#include "schedule/schedule_spec.h" +#include "scheduler_config.h" +#include "scheduler_result.h" class ESPWorker; +class ISchedulerExecutor; +struct CallbackRef; -enum class SchedulerJobMode : uint8_t { Inline, WorkerTask }; - -enum class ScheduleKind : uint8_t { - Cron, - OneShotUtc, - Sunrise, - Sunset, - MoonPhaseAngle, - MoonIlluminationPercent -}; - -enum class MoonPhaseName : uint8_t { - NewMoon, - WaxingCrescent, - FirstQuarter, - WaxingGibbous, - FullMoon, - WaningGibbous, - LastQuarter, - WaningCrescent -}; - -struct SchedulerTaskConfig { - const char *name = "sched-job"; - uint32_t stackSize = 4096; // bytes - UBaseType_t priority = 1; - BaseType_t coreId = tskNO_AFFINITY; - bool usePsramStack = false; -}; - -struct ESPSchedulerConfig { - // Prefer PSRAM-backed buffers for scheduler-owned dynamic containers. - // Falls back to default heap automatically when unavailable. - bool usePSRAMBuffers = false; -}; - -using SchedulerCallback = void (*)(void *userData); +using SchedulerCallbackFn = void (*)(void *userData); using SchedulerFunction = std::function; using SchedulerFunctionNoData = std::function; -class ScheduleField { - public: - static ScheduleField any(); - static ScheduleField only(int value); - static ScheduleField range(int from, int to); - static ScheduleField every(int step); - static ScheduleField rangeEvery(int from, int to, int step); - // If any value is out of range, the field is cleared and will fail validation. - static ScheduleField list(const int *values, size_t count); - - bool matches(int value) const; - bool isAny() const { - return m_isAny; - } - bool empty() const { - return !m_isAny && m_mask == 0; - } - uint64_t rawMask() const { - return m_mask; - } - - private: - uint64_t m_mask = 0; - bool m_isAny = false; -}; - -struct Schedule { - ScheduleKind kind = ScheduleKind::Cron; - bool isOneShot = false; - DateTime onceAtUtc{}; - - ScheduleField minute = ScheduleField::any(); - ScheduleField hour = ScheduleField::any(); - ScheduleField dayOfMonth = ScheduleField::any(); - ScheduleField month = ScheduleField::any(); - ScheduleField dayOfWeek = ScheduleField::any(); - - int sunOffsetMinutes = 0; - int moonPhaseAngleDegrees = 0; - int moonPhaseToleranceDegrees = 1; - double moonIlluminationTargetPercent = 0.0; - double moonIlluminationTolerancePercent = 0.5; - - static Schedule onceUtc(const DateTime &whenUtc); - static Schedule dailyAtLocal(int hour, int minute); - // dowMask bits: 0=Sun..6=Sat; empty mask falls back to any day of week. - static Schedule weeklyAtLocal(uint8_t dowMask, int hour, int minute); - static Schedule monthlyOnDayLocal(int dayOfMonth, int hour, int minute); - static Schedule sunrise(int offsetMinutes = 0); - static Schedule sunset(int offsetMinutes = 0); - static Schedule moonPhaseAngle(int angleDegrees, int toleranceDegrees = 1); - static Schedule moonPhase(MoonPhaseName name, int toleranceDegrees = 1); - static Schedule moonIlluminationPercent(double percent, double tolerancePercent = 0.5); - static Schedule custom( - const ScheduleField &minute, - const ScheduleField &hour, - const ScheduleField &dom, - const ScheduleField &month, - const ScheduleField &dow - ); +struct JobOptions { + DispatchPolicy dispatch = DispatchPolicy::Inline; + OverlapPolicy overlap = OverlapPolicy::SkipIfRunning; + uint8_t executorId = 0; + bool startPaused = false; + const char *name = nullptr; + const DedicatedTaskOptions *dedicatedTask = nullptr; }; struct JobInfo { uint32_t id = 0; - bool enabled = false; - SchedulerJobMode mode = SchedulerJobMode::Inline; - Schedule schedule{}; + const char *name = nullptr; + bool paused = false; + bool running = false; + bool queuedWhileRunning = false; + DispatchPolicy dispatch = DispatchPolicy::Inline; + OverlapPolicy overlap = OverlapPolicy::SkipIfRunning; + uint8_t executorId = 0; + bool hasNext = false; DateTime nextRunUtc{}; + ScheduleSpec schedule{}; }; class ESPScheduler { public: - // Default guard: block scheduling until at least 2020-01-01T00:00:00Z. static constexpr int64_t kDefaultMinValidEpochSeconds = 1577836800; + static constexpr uint8_t kInvalidExecutorId = 0xFF; - ESPScheduler(ESPDate &date, ESPWorker *worker = nullptr); - ESPScheduler(ESPDate &date, const ESPSchedulerConfig &config); - ESPScheduler(ESPDate &date, ESPWorker *worker, const ESPSchedulerConfig &config); + explicit ESPScheduler(ESPDate &date, const SchedulerConfig &config = SchedulerConfig{}); ~ESPScheduler(); - void deinit(); - bool isInitialized() const; - // Configure / inspect the minimum valid wall-clock time; scheduler idles until time >= min. - void setMinValidUnixSeconds(int64_t minEpochSeconds); - void setMinValidUtc(const DateTime &minUtc); - int64_t minValidUnixSeconds() const; + bool begin(); + void end(bool waitForRunningJobs = true, uint32_t timeoutMs = 5000); + bool running() const; + bool draining() const; - uint32_t addJobOnceUtc( - const DateTime &whenUtc, - SchedulerJobMode mode, - SchedulerCallback cb, - void *userData = nullptr, - const SchedulerTaskConfig *taskCfg = nullptr + SchedulerResult registerExecutor(ISchedulerExecutor *executor); + + SchedulerResult addJob( + const ScheduleSpec &schedule, + const JobOptions &options, + SchedulerCallbackFn callback, + void *userData = nullptr ); - uint32_t addJobOnceUtc( - const DateTime &whenUtc, - SchedulerJobMode mode, - SchedulerFunction cb, - void *userData = nullptr, - const SchedulerTaskConfig *taskCfg = nullptr + SchedulerResult addJob( + const ScheduleSpec &schedule, + const JobOptions &options, + SchedulerFunction callback, + void *userData = nullptr ); - uint32_t addJobOnceUtc( - const DateTime &whenUtc, - SchedulerJobMode mode, - SchedulerFunctionNoData cb, - const SchedulerTaskConfig *taskCfg = nullptr + SchedulerResult addJob( + const ScheduleSpec &schedule, const JobOptions &options, SchedulerFunctionNoData callback ); - uint32_t addJob( - const Schedule &schedule, - SchedulerJobMode mode, - SchedulerCallback cb, - void *userData = nullptr, - const SchedulerTaskConfig *taskCfg = nullptr + SchedulerResult addJobOnceUtc( + const DateTime &whenUtc, + const JobOptions &options, + SchedulerCallbackFn callback, + void *userData = nullptr ); - uint32_t addJob( - const Schedule &schedule, - SchedulerJobMode mode, - SchedulerFunction cb, - void *userData = nullptr, - const SchedulerTaskConfig *taskCfg = nullptr + SchedulerResult addJobOnceUtc( + const DateTime &whenUtc, + const JobOptions &options, + SchedulerFunction callback, + void *userData = nullptr ); - uint32_t addJob( - const Schedule &schedule, - SchedulerJobMode mode, - SchedulerFunctionNoData cb, - const SchedulerTaskConfig *taskCfg = nullptr + SchedulerResult addJobOnceUtc( + const DateTime &whenUtc, const JobOptions &options, SchedulerFunctionNoData callback ); - bool cancelJob(uint32_t jobId); - bool pauseJob(uint32_t jobId); - bool resumeJob(uint32_t jobId); - void cancelAll(); + SchedulerResult cancelJob(uint32_t jobId); + SchedulerResult pauseJob(uint32_t jobId); + SchedulerResult resumeJob(uint32_t jobId); + SchedulerResult cancelAll(); - void tick(const DateTime &nowUtc); void tick(); - void cleanup(); + void tick(const DateTime &nowUtc); + + SchedulerResult jobCount() const; + SchedulerResult getJobInfo(uint32_t jobId, JobInfo &out) const; + + void setMinValidUnixSeconds(int64_t minEpochSeconds); + void setMinValidUtc(const DateTime &minUtc); + int64_t minValidUnixSeconds() const; bool computeNextOccurrence( - const Schedule &schedule, const DateTime &fromUtc, DateTime &outNextUtc + const ScheduleSpec &schedule, const DateTime &fromUtc, DateTime &outNextUtc ) const; - bool getJobInfo(size_t index, JobInfo &out) const; + uint8_t defaultWorkerExecutor() const; + uint8_t defaultESPWorkerExecutor() const; + uint8_t defaultDedicatedExecutor() const; private: - struct InlineJob { - uint32_t id = 0; - Schedule schedule{}; - SchedulerFunction callback{}; - void *userData = nullptr; - DateTime nextRunUtc{}; - bool hasNext = false; - bool paused = false; - bool finished = false; - }; - - struct WorkerJobContext { - Schedule schedule{}; - SchedulerFunction callback{}; - void *userData = nullptr; - ESPDate *date = nullptr; - std::shared_ptr> minValidEpochSeconds{}; - std::atomic paused{false}; - std::atomic cancelRequested{false}; - std::atomic finished{false}; - DateTime nextRunUtc{}; - bool hasNext = false; - }; - - struct WorkerJob { - uint32_t id = 0; - std::shared_ptr context{}; - TaskHandle_t task = nullptr; - }; - - uint32_t nextId(); - bool validateSchedule(const Schedule &schedule) const; - bool fieldWithinRange(const ScheduleField &field, int min, int max) const; - uint64_t allowedMask(int min, int max) const; - static void runWorkerJob(const std::shared_ptr &ctx); - SchedulerTaskConfig makeTaskConfig(const SchedulerTaskConfig *taskCfg) const; - static void workerTaskEntry(void *arg); - void cleanupInline(); - void cleanupWorkers(); - bool clockValid(const DateTime &nowUtc) const; - void ensureInitialized(); - - ESPDate &m_date; - uint32_t m_nextId = 1; - int64_t m_minValidEpochSeconds = kDefaultMinValidEpochSeconds; - std::shared_ptr> m_minValidEpochSecondsRef; - std::atomic m_initialized{true}; - bool usePSRAMBuffers_ = false; - SchedulerVector m_inlineJobs; - SchedulerVector m_workerJobs; + struct Impl; + + SchedulerResult addJobImpl( + const ScheduleSpec &schedule, + const JobOptions &options, + const CallbackRef &callback + ); + + std::unique_ptr impl_; }; diff --git a/src/esp_scheduler/scheduler_allocator.h b/src/esp_scheduler/scheduler_allocator.h index dffd3ea..98a1068 100644 --- a/src/esp_scheduler/scheduler_allocator.h +++ b/src/esp_scheduler/scheduler_allocator.h @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -33,8 +34,72 @@ inline void deallocate(void *ptr) noexcept { std::free(ptr); #endif } + +inline void *reallocate(void *ptr, std::size_t bytes, bool usePSRAMBuffers) noexcept { +#if ESP_SCHEDULER_HAS_BUFFER_MANAGER + void *next = ESPBufferManager::allocate(bytes, usePSRAMBuffers); + if (!next) { + return nullptr; + } + if (ptr && next != ptr) { + std::memcpy(next, ptr, bytes); + ESPBufferManager::deallocate(ptr); + } + return next; +#else + (void)usePSRAMBuffers; + return std::realloc(ptr, bytes); +#endif +} } // namespace scheduler_allocator_detail +template T *schedulerAllocate(std::size_t count, bool usePSRAMBuffers) noexcept { + if (count == 0) { + return nullptr; + } + if (count > (std::numeric_limits::max() / sizeof(T))) { + return nullptr; + } + return static_cast( + scheduler_allocator_detail::allocate(count * sizeof(T), usePSRAMBuffers) + ); +} + +template void schedulerDeallocate(T *ptr) noexcept { + scheduler_allocator_detail::deallocate(ptr); +} + +template +T *schedulerReallocate(T *ptr, std::size_t oldCount, std::size_t newCount, bool usePSRAMBuffers) noexcept { + (void)oldCount; + if (newCount == 0) { + scheduler_allocator_detail::deallocate(ptr); + return nullptr; + } + if (newCount > (std::numeric_limits::max() / sizeof(T))) { + return nullptr; + } +#if ESP_SCHEDULER_HAS_BUFFER_MANAGER + T *next = schedulerAllocate(newCount, usePSRAMBuffers); + if (!next) { + return nullptr; + } + if (ptr) { + const std::size_t toCopy = oldCount < newCount ? oldCount : newCount; + for (std::size_t index = 0; index < toCopy; ++index) { + new (&next[index]) T(std::move(ptr[index])); + ptr[index].~T(); + } + schedulerDeallocate(ptr); + } + return next; +#else + return static_cast( + scheduler_allocator_detail::reallocate(ptr, newCount * sizeof(T), usePSRAMBuffers) + ); +#endif +} + template class SchedulerAllocator { public: using value_type = T; diff --git a/src/esp_scheduler/scheduler_config.h b/src/esp_scheduler/scheduler_config.h new file mode 100644 index 0000000..76c9c4e --- /dev/null +++ b/src/esp_scheduler/scheduler_config.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +extern "C" { +#include "freertos/FreeRTOS.h" +} + +#include + +class ESPWorker; + +enum class SchedulerMode : uint8_t { + Manual = 0, + Background, +}; + +enum class AsyncExecutorBackend : uint8_t { + WorkerPool = 0, + ESPWorker, +}; + +enum class DispatchPolicy : uint8_t { + Inline = 0, + Async, +}; + +enum class OverlapPolicy : uint8_t { + SkipIfRunning = 0, + QueueOne, + AllowParallel, +}; + +struct SchedulerServiceConfig { + uint32_t commandQueueDepth = 16; + uint32_t eventQueueDepth = 16; + uint32_t taskStackSize = 4096; + UBaseType_t taskPriority = 1; + BaseType_t coreId = tskNO_AFFINITY; + bool usePsramStack = false; + uint32_t controlTimeoutMs = 2000; +}; + +struct WorkerPoolConfig { + uint8_t workerCount = 1; + uint32_t queueDepth = 8; + uint32_t stackSize = 6144; + UBaseType_t priority = 1; + BaseType_t coreId = tskNO_AFFINITY; + bool usePsramStack = false; +}; + +struct DedicatedTaskOptions { + const char *name = "sched-job"; + uint32_t stackSize = 4096; + UBaseType_t priority = 1; + BaseType_t coreId = tskNO_AFFINITY; + bool usePsramStack = false; +}; + +struct SchedulerConfig { + SchedulerMode mode = SchedulerMode::Background; + int64_t minValidEpochSeconds = 1577836800; + bool usePSRAMMetadata = false; + AsyncExecutorBackend defaultAsyncBackend = AsyncExecutorBackend::WorkerPool; + ESPWorker *espWorker = nullptr; + SchedulerServiceConfig service{}; + WorkerPoolConfig defaultWorkerPool{}; + DedicatedTaskOptions defaultDedicatedTask{}; +}; diff --git a/src/esp_scheduler/scheduler_result.h b/src/esp_scheduler/scheduler_result.h new file mode 100644 index 0000000..f68cde9 --- /dev/null +++ b/src/esp_scheduler/scheduler_result.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +enum class SchedulerError : uint8_t { + Ok = 0, + NotInitialized, + AlreadyInitialized, + InvalidSchedule, + NoMemory, + QueueFull, + NotFound, + Busy, + ExecutorUnavailable, + Timeout, +}; + +template struct SchedulerResult { + SchedulerError error{SchedulerError::Ok}; + T value{}; + + bool ok() const { + return error == SchedulerError::Ok; + } + + explicit operator bool() const { + return ok(); + } + + static SchedulerResult success(const T &value) { + return {SchedulerError::Ok, value}; + } + + static SchedulerResult failure(SchedulerError error) { + return {error, T{}}; + } +}; + +template <> struct SchedulerResult { + SchedulerError error{SchedulerError::Ok}; + + bool ok() const { + return error == SchedulerError::Ok; + } + + explicit operator bool() const { + return ok(); + } + + static SchedulerResult success() { + return {SchedulerError::Ok}; + } + + static SchedulerResult failure(SchedulerError error) { + return {error}; + } +}; diff --git a/src/esp_scheduler/service/scheduler_commands.cpp b/src/esp_scheduler/service/scheduler_commands.cpp new file mode 100644 index 0000000..2a34bc5 --- /dev/null +++ b/src/esp_scheduler/service/scheduler_commands.cpp @@ -0,0 +1,84 @@ +#include "scheduler_commands.h" + +SchedulerServiceCommand::SchedulerServiceCommand() { + completion_ = xSemaphoreCreateBinaryStatic(&completionBuffer_); +} + +SchedulerServiceCommand::~SchedulerServiceCommand() { + if (completion_) { + vSemaphoreDelete(completion_); + completion_ = nullptr; + } +} + +bool SchedulerServiceCommand::wait(uint32_t timeoutMs) { + if (!completion_) { + return false; + } + return xSemaphoreTake(completion_, pdMS_TO_TICKS(timeoutMs)) == pdTRUE; +} + +void SchedulerServiceCommand::signal() { + if (completion_) { + xSemaphoreGive(completion_); + } +} + +void SchedulerServiceCommand::abandon() { + abandoned_.store(true, std::memory_order_release); +} + +bool SchedulerServiceCommand::abandoned() const { + return abandoned_.load(std::memory_order_acquire); +} + +void AddJobCommand::execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) { + (void)executors; + result = core.addJob(schedule, options, callback, date.now()); +} + +void CancelJobCommand::execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) { + (void)date; + (void)executors; + result = core.cancelJob(jobId); +} + +void PauseJobCommand::execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) { + (void)date; + (void)executors; + result = core.pauseJob(jobId); +} + +void ResumeJobCommand::execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) { + (void)executors; + result = core.resumeJob(jobId, date.now()); +} + +void CancelAllCommand::execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) { + (void)date; + (void)executors; + result = core.cancelAll(); +} + +void JobCountCommand::execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) { + (void)date; + (void)executors; + result = core.jobCount(); +} + +void GetJobInfoCommand::execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) { + (void)date; + (void)executors; + if (!info) { + result = SchedulerResult::failure(SchedulerError::NotFound); + return; + } + result = core.getJobInfo(jobId, *info); +} + +void SetMinValidCommand::execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) { + (void)date; + (void)executors; + core.setMinValidUnixSeconds(minEpochSeconds); + result = SchedulerResult::success(); +} diff --git a/src/esp_scheduler/service/scheduler_commands.h b/src/esp_scheduler/service/scheduler_commands.h new file mode 100644 index 0000000..fbe87c7 --- /dev/null +++ b/src/esp_scheduler/service/scheduler_commands.h @@ -0,0 +1,98 @@ +#pragma once + +#include + +#include "../core/scheduler_core.h" + +extern "C" { +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +} + +class SchedulerServiceCommand { + public: + SchedulerServiceCommand(); + virtual ~SchedulerServiceCommand(); + + bool wait(uint32_t timeoutMs); + void signal(); + void abandon(); + bool abandoned() const; + + virtual void execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) = 0; + + private: + SemaphoreHandle_t completion_ = nullptr; + StaticSemaphore_t completionBuffer_{}; + std::atomic abandoned_{false}; +}; + +class AddJobCommand : public SchedulerServiceCommand { + public: + ScheduleSpec schedule{}; + JobOptions options{}; + DedicatedTaskOptions dedicatedTaskCopy{}; + CallbackRef callback{}; + SchedulerResult result = SchedulerResult::failure( + SchedulerError::NotInitialized + ); + + void execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) override; +}; + +class CancelJobCommand : public SchedulerServiceCommand { + public: + uint32_t jobId = 0; + SchedulerResult result = SchedulerResult::failure(SchedulerError::NotInitialized); + + void execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) override; +}; + +class PauseJobCommand : public SchedulerServiceCommand { + public: + uint32_t jobId = 0; + SchedulerResult result = SchedulerResult::failure(SchedulerError::NotInitialized); + + void execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) override; +}; + +class ResumeJobCommand : public SchedulerServiceCommand { + public: + uint32_t jobId = 0; + SchedulerResult result = SchedulerResult::failure(SchedulerError::NotInitialized); + + void execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) override; +}; + +class CancelAllCommand : public SchedulerServiceCommand { + public: + SchedulerResult result = SchedulerResult::failure(SchedulerError::NotInitialized); + + void execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) override; +}; + +class JobCountCommand : public SchedulerServiceCommand { + public: + SchedulerResult result = SchedulerResult::failure( + SchedulerError::NotInitialized + ); + + void execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) override; +}; + +class GetJobInfoCommand : public SchedulerServiceCommand { + public: + uint32_t jobId = 0; + JobInfo *info = nullptr; + SchedulerResult result = SchedulerResult::failure(SchedulerError::NotInitialized); + + void execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) override; +}; + +class SetMinValidCommand : public SchedulerServiceCommand { + public: + int64_t minEpochSeconds = 0; + SchedulerResult result = SchedulerResult::failure(SchedulerError::NotInitialized); + + void execute(SchedulerCore &core, ESPDate &date, IExecutorResolver &executors) override; +}; diff --git a/src/esp_scheduler/service/scheduler_events.h b/src/esp_scheduler/service/scheduler_events.h new file mode 100644 index 0000000..9dd074d --- /dev/null +++ b/src/esp_scheduler/service/scheduler_events.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +enum class SchedulerEventKind : uint8_t { + JobFinished = 0, +}; + +struct SchedulerEvent { + SchedulerEventKind kind = SchedulerEventKind::JobFinished; + uint32_t jobId = 0; + uint32_t generation = 0; + size_t slotIndex = 0; +}; diff --git a/src/esp_scheduler/service/scheduler_service.cpp b/src/esp_scheduler/service/scheduler_service.cpp new file mode 100644 index 0000000..93774ce --- /dev/null +++ b/src/esp_scheduler/service/scheduler_service.cpp @@ -0,0 +1,204 @@ +#include "scheduler_service.h" + +#include + +#include "../executors/task_support.h" + +namespace { +constexpr uint32_t kIdlePollMs = 1000; +} + +SchedulerService::SchedulerService( + ESPDate &date, + const SchedulerServiceConfig &config, + int64_t minValidEpochSeconds, + bool usePSRAMMetadata, + IExecutorResolver &executors +) + : date_(date), + config_(config), + core_(date, minValidEpochSeconds, usePSRAMMetadata), + executors_(executors) { +} + +SchedulerService::~SchedulerService() { + stop(); +} + +bool SchedulerService::begin() { + if (started_.load()) { + return true; + } + + commandQueue_ = xQueueCreate(config_.commandQueueDepth, sizeof(SchedulerServiceCommand *)); + eventQueue_ = xQueueCreate(config_.eventQueueDepth, sizeof(SchedulerEvent)); + if (!commandQueue_ || !eventQueue_) { + stop(); + return false; + } + + queueSet_ = xQueueCreateSet(config_.commandQueueDepth + config_.eventQueueDepth); + if (!queueSet_) { + stop(); + return false; + } + xQueueAddToSet(commandQueue_, queueSet_); + xQueueAddToSet(eventQueue_, queueSet_); + + bool createdWithCaps = false; + const BaseType_t created = scheduler_task_support::createTaskPinned( + &SchedulerService::taskEntry, + "sched-svc", + config_.taskStackSize, + this, + config_.taskPriority, + &task_, + config_.coreId, + config_.usePsramStack, + createdWithCaps + ); + if (created != pdPASS || task_ == nullptr) { + stop(); + return false; + } + taskCreatedWithCaps_ = createdWithCaps; + + started_.store(true); + return true; +} + +void SchedulerService::stop() { + if (!commandQueue_ && !eventQueue_ && !queueSet_ && !task_) { + started_.store(false); + return; + } + + stopRequested_.store(true); + SchedulerServiceCommand *wake = nullptr; + if (commandQueue_) { + xQueueSend(commandQueue_, &wake, 0); + } + + const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(3000); + while (task_ && !taskExited_.load() && xTaskGetTickCount() < deadline) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + + if (task_ && !taskExited_.load()) { + scheduler_task_support::deleteTask(task_, taskCreatedWithCaps_); + } + task_ = nullptr; + taskCreatedWithCaps_ = false; + + if (queueSet_) { + vQueueDelete(queueSet_); + queueSet_ = nullptr; + } + if (commandQueue_) { + while (true) { + SchedulerServiceCommand *pending = nullptr; + if (xQueueReceive(commandQueue_, &pending, 0) != pdTRUE) { + break; + } + if (!pending) { + continue; + } + pending->signal(); + if (pending->abandoned()) { + delete pending; + } + } + } + if (commandQueue_) { + vQueueDelete(commandQueue_); + commandQueue_ = nullptr; + } + if (eventQueue_) { + vQueueDelete(eventQueue_); + eventQueue_ = nullptr; + } + + taskExited_.store(false); + stopRequested_.store(false); + started_.store(false); +} + +bool SchedulerService::send(SchedulerServiceCommand *command) { + if (!commandQueue_) { + return false; + } + return xQueueSend(commandQueue_, &command, 0) == pdTRUE; +} + +void SchedulerService::taskEntry(void *arg) { + SchedulerService *service = static_cast(arg); + if (!service) { + vTaskDelete(nullptr); + return; + } + service->run(); + service->taskExited_.store(true); + service->task_ = nullptr; + scheduler_task_support::deleteCurrentTask(service->taskCreatedWithCaps_); +} + +void SchedulerService::drainCommands() { + if (!commandQueue_) { + return; + } + while (true) { + SchedulerServiceCommand *command = nullptr; + if (xQueueReceive(commandQueue_, &command, 0) != pdTRUE) { + break; + } + if (!command) { + continue; + } + command->execute(core_, date_, executors_); + command->signal(); + if (command->abandoned()) { + delete command; + } + } +} + +void SchedulerService::drainEvents() { + if (!eventQueue_) { + return; + } + while (true) { + SchedulerEvent event{}; + if (xQueueReceive(eventQueue_, &event, 0) != pdTRUE) { + break; + } + core_.handleEvent(event, date_.now(), executors_); + } +} + +void SchedulerService::run() { + while (!stopRequested_.load()) { + drainCommands(); + drainEvents(); + + const DateTime nowUtc = date_.now(); + core_.dispatchDue(nowUtc, executors_); + activeInvocationCount_.store(core_.activeInvocationCount()); + + int64_t nextEpochSeconds = 0; + TickType_t waitTicks = pdMS_TO_TICKS(kIdlePollMs); + if (core_.clockValid(nowUtc) && core_.nextDueEpoch(nextEpochSeconds)) { + if (nextEpochSeconds <= nowUtc.epochSeconds) { + waitTicks = 0; + } else { + const int64_t waitSeconds = nextEpochSeconds - nowUtc.epochSeconds; + waitTicks = pdMS_TO_TICKS(static_cast(waitSeconds * 1000)); + } + } + + QueueSetMemberHandle_t ready = + queueSet_ ? xQueueSelectFromSet(queueSet_, waitTicks) : nullptr; + if (!ready) { + continue; + } + } +} diff --git a/src/esp_scheduler/service/scheduler_service.h b/src/esp_scheduler/service/scheduler_service.h new file mode 100644 index 0000000..0f4a0e7 --- /dev/null +++ b/src/esp_scheduler/service/scheduler_service.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +#include "../core/scheduler_core.h" +#include "../executors/scheduler_executor.h" +#include "scheduler_commands.h" + +class SchedulerService { + public: + SchedulerService( + ESPDate &date, + const SchedulerServiceConfig &config, + int64_t minValidEpochSeconds, + bool usePSRAMMetadata, + IExecutorResolver &executors + ); + ~SchedulerService(); + + bool begin(); + void stop(); + + bool send(SchedulerServiceCommand *command); + + QueueHandle_t eventQueue() const { + return eventQueue_; + } + + size_t activeInvocationCount() const { + return activeInvocationCount_.load(); + } + + private: + static void taskEntry(void *arg); + + void run(); + void drainCommands(); + void drainEvents(); + + ESPDate &date_; + SchedulerServiceConfig config_{}; + SchedulerCore core_; + IExecutorResolver &executors_; + + QueueHandle_t commandQueue_ = nullptr; + QueueHandle_t eventQueue_ = nullptr; + QueueSetHandle_t queueSet_ = nullptr; + TaskHandle_t task_ = nullptr; + bool taskCreatedWithCaps_ = false; + + std::atomic started_{false}; + std::atomic stopRequested_{false}; + std::atomic taskExited_{false}; + std::atomic activeInvocationCount_{0}; +}; diff --git a/test/test_esp_scheduler/test_esp_scheduler.cpp b/test/test_esp_scheduler/test_esp_scheduler.cpp index 17761ec..ff2ba73 100644 --- a/test/test_esp_scheduler/test_esp_scheduler.cpp +++ b/test/test_esp_scheduler/test_esp_scheduler.cpp @@ -1,19 +1,47 @@ #include #include +#include #include #include +#include +#include +#include #include +#include "esp_scheduler/executors/scheduler_executor.h" +#include "esp_scheduler/service/scheduler_events.h" + ESPDate date; -ESPScheduler scheduler(date); + +SchedulerConfig manualConfig() { + SchedulerConfig config{}; + config.mode = SchedulerMode::Manual; + config.service.eventQueueDepth = 16; + return config; +} + +ESPScheduler scheduler(date, manualConfig()); static int inlineHits = 0; +static int asyncHits = 0; +static int slowHits = 0; static void inlineCallback(void *userData) { (void)userData; inlineHits++; } +static void asyncCallback(void *userData) { + (void)userData; + asyncHits++; +} + +static void slowCallback(void *userData) { + (void)userData; + slowHits++; + delay(250); +} + static double circularDistanceDegrees(double a, double b) { double delta = std::fmod(std::fabs(a - b), 360.0); if (delta > 180.0) { @@ -22,162 +50,205 @@ static double circularDistanceDegrees(double a, double b) { return delta; } +class TestQueueExecutor : public ISchedulerExecutor { + public: + bool begin(const std::shared_ptr &runtime) override { + runtime_ = runtime; + count_ = 0; + return true; + } + + void end(bool drainRunningJobs) override { + (void)drainRunningJobs; + runtime_.reset(); + count_ = 0; + } + + bool submit(const JobInvocation &invocation) override { + if (count_ >= kMaxInvocations) { + return false; + } + invocations_[count_++] = invocation; + return true; + } + + const char *name() const override { + return "test-queue"; + } + + size_t queued() const { + return count_; + } + + void completeOne() { + TEST_ASSERT_TRUE(count_ > 0); + JobInvocation invocation = invocations_[0]; + for (size_t index = 1; index < count_; ++index) { + invocations_[index - 1] = invocations_[index]; + } + --count_; + invocation.callback.invoke(); + SchedulerEvent event{}; + event.kind = SchedulerEventKind::JobFinished; + event.jobId = invocation.jobId; + event.generation = invocation.generation; + event.slotIndex = invocation.slotIndex; + TEST_ASSERT_NOT_NULL(runtime_->eventQueue); + TEST_ASSERT_EQUAL(pdTRUE, xQueueSend(runtime_->eventQueue, &event, 0)); + } + + private: + static constexpr size_t kMaxInvocations = 8; + JobInvocation invocations_[kMaxInvocations]{}; + size_t count_ = 0; + std::shared_ptr runtime_{}; +}; + +static void test_begin_is_idempotent_and_end_is_explicit() { + ESPScheduler local(date, manualConfig()); + TEST_ASSERT_FALSE(local.running()); + TEST_ASSERT_TRUE(local.begin()); + TEST_ASSERT_TRUE(local.begin()); + TEST_ASSERT_TRUE(local.running()); + local.end(true); + TEST_ASSERT_FALSE(local.running()); +} + static void test_daily_at_local_next_same_day() { Schedule s = Schedule::dailyAtLocal(9, 30); DateTime from = date.fromUtc(2025, 1, 1, 8, 15, 10); - DateTime expected = date.fromUtc(2025, 1, 1, 9, 30, 0); DateTime next{}; TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(s, from, next)); - TEST_ASSERT_TRUE(date.isEqual(expected, next)); + TEST_ASSERT_TRUE(date.isEqual(next, date.fromUtc(2025, 1, 1, 9, 30, 0))); } static void test_daily_at_local_rolls_to_next_day() { Schedule s = Schedule::dailyAtLocal(6, 0); - DateTime from = date.fromUtc(2025, 1, 1, 7, 0, 1); // already past the slot + DateTime from = date.fromUtc(2025, 1, 1, 7, 0, 1); DateTime next{}; TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(s, from, next)); TEST_ASSERT_TRUE(date.isEqual(next, date.fromUtc(2025, 1, 2, 6, 0, 0))); } static void test_weekly_mask_advances_to_next_weekday() { - uint8_t weekdaysMask = 0b0111110; // Mon..Fri + uint8_t weekdaysMask = 0b0111110; Schedule s = Schedule::weeklyAtLocal(weekdaysMask, 18, 30); - DateTime from = date.fromUtc(2025, 3, 4, 19, 0, 0); // Tuesday 19:00 UTC + DateTime from = date.fromUtc(2025, 3, 4, 19, 0, 0); DateTime next{}; TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(s, from, next)); - TEST_ASSERT_TRUE(date.isEqual(next, date.fromUtc(2025, 3, 5, 18, 30, 0))); // Wednesday 18:30 -} - -static void test_weekly_zero_mask_defaults_to_any_day() { - Schedule s = Schedule::weeklyAtLocal(0, 10, 45); - DateTime from = date.fromUtc(2025, 3, 1, 10, 0, 0); - DateTime next{}; - TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(s, from, next)); - TEST_ASSERT_TRUE(date.isEqual(next, date.fromUtc(2025, 3, 1, 10, 45, 0))); + TEST_ASSERT_TRUE(date.isEqual(next, date.fromUtc(2025, 3, 5, 18, 30, 0))); } static void test_dom_dow_or_logic_matches_either() { - ScheduleField dom = ScheduleField::only(10); - ScheduleField dow = ScheduleField::only(1); // Monday = 1 with ESPDate (0=Sun) Schedule s = Schedule::custom( ScheduleField::only(0), ScheduleField::only(9), - dom, + ScheduleField::only(10), ScheduleField::any(), - dow + ScheduleField::only(1) ); - DateTime from = date.fromUtc(2024, 7, 1, 8, 0, 0); // Monday, day 1 + DateTime from = date.fromUtc(2024, 7, 1, 8, 0, 0); DateTime next{}; TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(s, from, next)); - TEST_ASSERT_TRUE( - date.isEqual(next, date.fromUtc(2024, 7, 1, 9, 0, 0)) - ); // passes via DOW even though DOM mismatch + TEST_ASSERT_TRUE(date.isEqual(next, date.fromUtc(2024, 7, 1, 9, 0, 0))); } static void test_inline_tick_runs_and_reschedules() { - inlineHits = 0; - Schedule s = Schedule::dailyAtLocal(6, 0); - uint32_t id = scheduler.addJob(s, SchedulerJobMode::Inline, &inlineCallback, nullptr); - TEST_ASSERT_NOT_EQUAL(0u, id); + JobOptions options{}; + SchedulerResult added = + scheduler.addJob(Schedule::dailyAtLocal(6, 0), options, &inlineCallback, nullptr); + TEST_ASSERT_TRUE(added.ok()); - DateTime first = date.fromUtc(2025, 1, 1, 6, 0, 0); - scheduler.tick(first); + scheduler.tick(date.fromUtc(2025, 1, 1, 6, 0, 0)); TEST_ASSERT_EQUAL(1, inlineHits); - // Same day later should not trigger again scheduler.tick(date.fromUtc(2025, 1, 1, 23, 0, 0)); TEST_ASSERT_EQUAL(1, inlineHits); - // Next day at slot should run again scheduler.tick(date.fromUtc(2025, 1, 2, 6, 0, 0)); TEST_ASSERT_EQUAL(2, inlineHits); } -static void test_get_job_info_reports_next_run() { - inlineHits = 0; - Schedule s = Schedule::dailyAtLocal(6, 0); - uint32_t id = scheduler.addJob(s, SchedulerJobMode::Inline, &inlineCallback, nullptr); - TEST_ASSERT_NOT_EQUAL(0u, id); +static void test_get_job_info_reports_next_run_by_job_id() { + JobOptions options{}; + SchedulerResult added = + scheduler.addJob(Schedule::dailyAtLocal(6, 0), options, &inlineCallback, nullptr); + TEST_ASSERT_TRUE(added.ok()); - DateTime now = date.fromUtc(2025, 1, 1, 0, 0, 0); - scheduler.tick(now); // compute next run but do not fire - TEST_ASSERT_EQUAL(0, inlineHits); + scheduler.tick(date.fromUtc(2025, 1, 1, 0, 0, 0)); JobInfo info{}; - TEST_ASSERT_TRUE(scheduler.getJobInfo(0, info)); - TEST_ASSERT_EQUAL(id, info.id); - TEST_ASSERT_TRUE(info.enabled); - TEST_ASSERT_EQUAL(static_cast(SchedulerJobMode::Inline), static_cast(info.mode)); + TEST_ASSERT_TRUE(scheduler.getJobInfo(added.value, info).ok()); + TEST_ASSERT_EQUAL(added.value, info.id); + TEST_ASSERT_FALSE(info.paused); + TEST_ASSERT_TRUE(info.hasNext); TEST_ASSERT_TRUE(date.isEqual(info.nextRunUtc, date.fromUtc(2025, 1, 1, 6, 0, 0))); } -static void test_tick_waits_until_clock_valid() { - inlineHits = 0; - Schedule s = Schedule::dailyAtLocal(6, 0); - uint32_t id = scheduler.addJob(s, SchedulerJobMode::Inline, &inlineCallback, nullptr); - TEST_ASSERT_NOT_EQUAL(0u, id); +static void test_tick_waits_until_clock_valid_and_primes_once() { + JobOptions options{}; + SchedulerResult added = + scheduler.addJobOnceUtc(date.fromUtc(2025, 1, 1, 6, 0, 0), options, &inlineCallback, nullptr); + TEST_ASSERT_TRUE(added.ok()); - DateTime invalid = date.fromUtc(1970, 1, 1, 0, 0, 0); - scheduler.tick(invalid); + scheduler.tick(date.fromUtc(1970, 1, 1, 0, 0, 0)); TEST_ASSERT_EQUAL(0, inlineHits); - DateTime valid = date.fromUtc(2025, 1, 1, 6, 0, 0); - scheduler.tick(valid); + scheduler.tick(date.fromUtc(2025, 1, 1, 6, 0, 0)); TEST_ASSERT_EQUAL(1, inlineHits); -} - -static void test_psram_buffer_config_constructor_adds_inline_job() { - ESPSchedulerConfig cfg{}; - cfg.usePSRAMBuffers = true; - ESPScheduler localScheduler(date, cfg); - - uint32_t id = localScheduler.addJob( - Schedule::dailyAtLocal(12, 0), - SchedulerJobMode::Inline, - &inlineCallback, - nullptr - ); - TEST_ASSERT_NOT_EQUAL(0u, id); - localScheduler.cancelAll(); + scheduler.tick(date.fromUtc(2025, 1, 1, 6, 1, 0)); + TEST_ASSERT_EQUAL(1, inlineHits); } -static void test_deinit_is_idempotent_and_safe_when_uninitialized() { - ESPScheduler localScheduler(date); - TEST_ASSERT_TRUE(localScheduler.isInitialized()); +static void test_pause_resume_cancel_and_job_count() { + JobOptions options{}; + SchedulerResult first = + scheduler.addJob(Schedule::dailyAtLocal(6, 0), options, &inlineCallback, nullptr); + SchedulerResult second = + scheduler.addJob(Schedule::dailyAtLocal(7, 0), options, &inlineCallback, nullptr); + TEST_ASSERT_TRUE(first.ok()); + TEST_ASSERT_TRUE(second.ok()); + TEST_ASSERT_EQUAL(static_cast(2), scheduler.jobCount().value); + + TEST_ASSERT_TRUE(scheduler.pauseJob(first.value).ok()); + TEST_ASSERT_TRUE(scheduler.resumeJob(first.value).ok()); + TEST_ASSERT_TRUE(scheduler.cancelJob(second.value).ok()); + TEST_ASSERT_EQUAL(static_cast(1), scheduler.jobCount().value); +} - DateTime when = date.fromUtc(2025, 1, 1, 12, 0, 0); - uint32_t id = - localScheduler.addJobOnceUtc(when, SchedulerJobMode::Inline, &inlineCallback, nullptr); - TEST_ASSERT_NOT_EQUAL(0u, id); +static void test_slot_reuse_keeps_old_job_id_invalid() { + ESPScheduler local(date, manualConfig()); + TEST_ASSERT_TRUE(local.begin()); - localScheduler.deinit(); - TEST_ASSERT_FALSE(localScheduler.isInitialized()); + JobOptions options{}; + SchedulerResult first = + local.addJobOnceUtc(date.fromUtc(2025, 1, 1, 6, 0, 0), options, &inlineCallback, nullptr); + TEST_ASSERT_TRUE(first.ok()); + local.tick(date.fromUtc(2025, 1, 1, 6, 0, 0)); JobInfo info{}; - TEST_ASSERT_FALSE(localScheduler.getJobInfo(0, info)); - TEST_ASSERT_FALSE(localScheduler.cancelJob(id)); - TEST_ASSERT_FALSE(localScheduler.pauseJob(id)); - TEST_ASSERT_FALSE(localScheduler.resumeJob(id)); + TEST_ASSERT_EQUAL(SchedulerError::NotFound, local.getJobInfo(first.value, info).error); - localScheduler.deinit(); - TEST_ASSERT_FALSE(localScheduler.isInitialized()); -} + SchedulerResult second = + local.addJob(Schedule::dailyAtLocal(7, 0), options, &inlineCallback, nullptr); + TEST_ASSERT_TRUE(second.ok()); + TEST_ASSERT_TRUE(first.value != second.value); -static void test_scheduler_reinitializes_after_deinit() { - ESPScheduler localScheduler(date); - localScheduler.deinit(); - TEST_ASSERT_FALSE(localScheduler.isInitialized()); - - inlineHits = 0; - DateTime when = date.fromUtc(2025, 1, 1, 12, 0, 0); - uint32_t id = - localScheduler.addJobOnceUtc(when, SchedulerJobMode::Inline, &inlineCallback, nullptr); - TEST_ASSERT_NOT_EQUAL(0u, id); - TEST_ASSERT_TRUE(localScheduler.isInitialized()); + local.tick(date.fromUtc(2025, 1, 1, 6, 1, 0)); + TEST_ASSERT_TRUE(local.getJobInfo(second.value, info).ok()); + TEST_ASSERT_EQUAL(second.value, info.id); + local.end(true); +} - localScheduler.tick(when); - TEST_ASSERT_EQUAL(1, inlineHits); +static void test_executor_unavailable_is_reported() { + JobOptions options{}; + options.dispatch = DispatchPolicy::Async; + options.executorId = 99; + TEST_ASSERT_EQUAL( + SchedulerError::ExecutorUnavailable, + scheduler.addJob(Schedule::dailyAtLocal(6, 0), options, &asyncCallback, nullptr).error + ); } static void test_sunrise_next_occurrence_with_offsets() { @@ -189,13 +260,8 @@ static void test_sunrise_next_occurrence_with_offsets() { TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(Schedule::sunrise(), from, next)); TEST_ASSERT_TRUE(date.isEqual(next, riseToday.value)); - DateTime expectedPlus = date.addMinutes(riseToday.value, 30); TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(Schedule::sunrise(30), from, next)); - TEST_ASSERT_TRUE(date.isEqual(next, expectedPlus)); - - DateTime expectedMinus = date.addMinutes(riseToday.value, -30); - TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(Schedule::sunrise(-30), from, next)); - TEST_ASSERT_TRUE(date.isEqual(next, expectedMinus)); + TEST_ASSERT_TRUE(date.isEqual(next, date.addMinutes(riseToday.value, 30))); } static void test_sunset_next_occurrence_with_offsets() { @@ -207,20 +273,16 @@ static void test_sunset_next_occurrence_with_offsets() { TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(Schedule::sunset(), from, next)); TEST_ASSERT_TRUE(date.isEqual(next, setToday.value)); - DateTime expectedPlus = date.addMinutes(setToday.value, 20); - TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(Schedule::sunset(20), from, next)); - TEST_ASSERT_TRUE(date.isEqual(next, expectedPlus)); - - DateTime expectedMinus = date.addMinutes(setToday.value, -20); TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(Schedule::sunset(-20), from, next)); - TEST_ASSERT_TRUE(date.isEqual(next, expectedMinus)); + TEST_ASSERT_TRUE(date.isEqual(next, date.addMinutes(setToday.value, -20))); } static void test_moon_phase_name_last_quarter_next_occurrence() { - Schedule phaseSchedule = Schedule::moonPhase(MoonPhaseName::LastQuarter, 2); DateTime from = date.fromUtc(2024, 3, 25, 0, 0, 0); DateTime next{}; - TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(phaseSchedule, from, next)); + TEST_ASSERT_TRUE( + scheduler.computeNextOccurrence(Schedule::moonPhase(MoonPhaseName::LastQuarter, 2), from, next) + ); TEST_ASSERT_TRUE(date.differenceInDays(next, from) <= 40); MoonPhaseResult phaseAtNext = date.moonPhase(next); @@ -245,108 +307,257 @@ static void test_moon_illumination_crossing_and_reschedule() { scheduler.computeNextOccurrence(illumSchedule, date.addMinutes(first, 1), second) ); TEST_ASSERT_TRUE(date.isAfter(second, first)); - TEST_ASSERT_TRUE(date.differenceInHours(second, first) > 24); } static void test_invalid_astronomical_schedule_validation() { - TEST_ASSERT_EQUAL( - 0u, - scheduler - .addJob(Schedule::sunrise(1500), SchedulerJobMode::Inline, &inlineCallback, nullptr) - ); - TEST_ASSERT_EQUAL( - 0u, - scheduler - .addJob(Schedule::sunset(-1500), SchedulerJobMode::Inline, &inlineCallback, nullptr) - ); - TEST_ASSERT_EQUAL( - 0u, - scheduler.addJob( - Schedule::moonPhaseAngle(-1, 1), - SchedulerJobMode::Inline, - &inlineCallback, - nullptr - ) - ); - TEST_ASSERT_EQUAL( - 0u, - scheduler.addJob( - Schedule::moonPhaseAngle(360, 1), - SchedulerJobMode::Inline, - &inlineCallback, - nullptr - ) - ); - TEST_ASSERT_EQUAL( - 0u, - scheduler.addJob( - Schedule::moonPhaseAngle(270, 31), - SchedulerJobMode::Inline, - &inlineCallback, - nullptr - ) + JobOptions options{}; + TEST_ASSERT_FALSE( + scheduler.addJob(Schedule::sunrise(1500), options, &inlineCallback, nullptr).ok() ); - TEST_ASSERT_EQUAL( - 0u, - scheduler.addJob( - Schedule::moonIlluminationPercent(101.0, 0.5), - SchedulerJobMode::Inline, - &inlineCallback, - nullptr - ) + TEST_ASSERT_FALSE( + scheduler.addJob(Schedule::moonPhaseAngle(360, 1), options, &inlineCallback, nullptr).ok() ); - TEST_ASSERT_EQUAL( - 0u, + TEST_ASSERT_FALSE( scheduler.addJob( - Schedule::moonIlluminationPercent(50.0, 0.0), - SchedulerJobMode::Inline, - &inlineCallback, - nullptr - ) + Schedule::moonIlluminationPercent(50.0, 0.0), + options, + &inlineCallback, + nullptr + ) + .ok() ); - TEST_ASSERT_EQUAL( - 0u, - scheduler.addJob( - Schedule::moonIlluminationPercent(50.0, 51.0), - SchedulerJobMode::Inline, - &inlineCallback, - nullptr - ) +} + +static void test_skip_if_running_behavior() { + SchedulerConfig config = manualConfig(); + ESPScheduler local(date, config); + TestQueueExecutor executor; + SchedulerResult executorId = local.registerExecutor(&executor); + TEST_ASSERT_TRUE(executorId.ok()); + TEST_ASSERT_TRUE(local.begin()); + + JobOptions options{}; + options.dispatch = DispatchPolicy::Async; + options.executorId = executorId.value; + + SchedulerResult skipJob = + local.addJob(Schedule::dailyAtLocal(6, 0), options, &asyncCallback, nullptr); + TEST_ASSERT_TRUE(skipJob.ok()); + + local.tick(date.fromUtc(2025, 1, 1, 6, 0, 0)); + TEST_ASSERT_EQUAL(static_cast(1), executor.queued()); + local.tick(date.fromUtc(2025, 1, 2, 6, 0, 0)); + TEST_ASSERT_EQUAL(static_cast(1), executor.queued()); + executor.completeOne(); + local.tick(date.fromUtc(2025, 1, 2, 6, 0, 0)); + TEST_ASSERT_EQUAL(1, asyncHits); + TEST_ASSERT_EQUAL(static_cast(0), executor.queued()); + local.tick(date.fromUtc(2025, 1, 3, 6, 0, 0)); + TEST_ASSERT_EQUAL(static_cast(1), executor.queued()); + + local.end(true); +} + +static void test_queue_one_behavior() { + SchedulerConfig config = manualConfig(); + ESPScheduler local(date, config); + TestQueueExecutor executor; + SchedulerResult executorId = local.registerExecutor(&executor); + TEST_ASSERT_TRUE(executorId.ok()); + TEST_ASSERT_TRUE(local.begin()); + + JobOptions options{}; + options.dispatch = DispatchPolicy::Async; + options.executorId = executorId.value; + options.overlap = OverlapPolicy::QueueOne; + SchedulerResult added = + local.addJob(Schedule::dailyAtLocal(6, 0), options, &asyncCallback, nullptr); + TEST_ASSERT_TRUE(added.ok()); + + local.tick(date.fromUtc(2025, 1, 1, 6, 0, 0)); + local.tick(date.fromUtc(2025, 1, 2, 6, 0, 0)); + local.tick(date.fromUtc(2025, 1, 3, 6, 0, 0)); + TEST_ASSERT_EQUAL(static_cast(1), executor.queued()); + + executor.completeOne(); + local.tick(date.fromUtc(2025, 1, 3, 6, 0, 0)); + TEST_ASSERT_EQUAL(static_cast(1), executor.queued()); + executor.completeOne(); + local.tick(date.fromUtc(2025, 1, 3, 6, 1, 0)); + TEST_ASSERT_EQUAL(2, asyncHits); + local.end(true); +} + +static void test_allow_parallel_behavior() { + SchedulerConfig config = manualConfig(); + ESPScheduler local(date, config); + TestQueueExecutor executor; + SchedulerResult executorId = local.registerExecutor(&executor); + TEST_ASSERT_TRUE(executorId.ok()); + TEST_ASSERT_TRUE(local.begin()); + + JobOptions options{}; + options.dispatch = DispatchPolicy::Async; + options.executorId = executorId.value; + options.overlap = OverlapPolicy::AllowParallel; + SchedulerResult added = + local.addJob(Schedule::dailyAtLocal(6, 0), options, &asyncCallback, nullptr); + TEST_ASSERT_TRUE(added.ok()); + + local.tick(date.fromUtc(2025, 1, 1, 6, 0, 0)); + local.tick(date.fromUtc(2025, 1, 2, 6, 0, 0)); + TEST_ASSERT_EQUAL(static_cast(2), executor.queued()); + executor.completeOne(); + executor.completeOne(); + local.tick(date.fromUtc(2025, 1, 2, 6, 1, 0)); + TEST_ASSERT_EQUAL(2, asyncHits); + local.end(true); +} + +static void test_cancel_running_async_job_and_stale_completion_is_ignored() { + SchedulerConfig config = manualConfig(); + ESPScheduler local(date, config); + TestQueueExecutor executor; + SchedulerResult executorId = local.registerExecutor(&executor); + TEST_ASSERT_TRUE(executorId.ok()); + TEST_ASSERT_TRUE(local.begin()); + + JobOptions options{}; + options.dispatch = DispatchPolicy::Async; + options.executorId = executorId.value; + SchedulerResult added = + local.addJob(Schedule::dailyAtLocal(6, 0), options, &asyncCallback, nullptr); + TEST_ASSERT_TRUE(added.ok()); + + local.tick(date.fromUtc(2025, 1, 1, 6, 0, 0)); + TEST_ASSERT_EQUAL(static_cast(1), executor.queued()); + TEST_ASSERT_TRUE(local.cancelJob(added.value).ok()); + executor.completeOne(); + local.tick(date.fromUtc(2025, 1, 1, 6, 1, 0)); + + JobInfo info{}; + TEST_ASSERT_EQUAL(SchedulerError::NotFound, local.getJobInfo(added.value, info).error); + local.end(true); +} + +static void test_background_async_runs_without_tick() { + SchedulerConfig config{}; + config.mode = SchedulerMode::Background; + ESPScheduler background(date, config); + TEST_ASSERT_TRUE(background.begin()); + background.setMinValidUnixSeconds(0); + + JobOptions asyncOptions{}; + asyncOptions.dispatch = DispatchPolicy::Async; + SchedulerResult added = background.addJobOnceUtc( + date.addSeconds(date.now(), 1), + asyncOptions, + &asyncCallback, + nullptr ); + TEST_ASSERT_TRUE(added.ok()); + + const uint32_t startedMs = millis(); + while (asyncHits == 0 && (millis() - startedMs) < 5000) { + delay(25); + } + TEST_ASSERT_EQUAL(1, asyncHits); + background.end(true); } -static void test_tick_runs_sunrise_and_moon_schedules() { - inlineHits = 0; - DateTime sunriseFrom = date.fromUtc(2025, 6, 1, 0, 0, 0); - DateTime sunriseDue{}; - TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(Schedule::sunrise(), sunriseFrom, sunriseDue)); - - uint32_t sunriseId = - scheduler.addJob(Schedule::sunrise(), SchedulerJobMode::Inline, &inlineCallback, nullptr); - TEST_ASSERT_NOT_EQUAL(0u, sunriseId); - scheduler.tick(sunriseDue); - TEST_ASSERT_EQUAL(1, inlineHits); +static void test_begin_fails_for_missing_builtin_espworker() { + SchedulerConfig config = manualConfig(); + config.defaultAsyncBackend = AsyncExecutorBackend::ESPWorker; + config.espWorker = nullptr; + ESPScheduler local(date, config); + TEST_ASSERT_FALSE(local.begin()); + TEST_ASSERT_EQUAL(ESPScheduler::kInvalidExecutorId, local.defaultESPWorkerExecutor()); +} - scheduler.cancelAll(); +static void test_builtin_espworker_executor_id_available_when_configured() { + ESPWorker worker; + ESPWorker::Config workerConfig{}; + worker.init(workerConfig); + + SchedulerConfig config = manualConfig(); + config.defaultAsyncBackend = AsyncExecutorBackend::ESPWorker; + config.espWorker = &worker; + ESPScheduler local(date, config); + TEST_ASSERT_EQUAL(0, local.defaultESPWorkerExecutor()); + TEST_ASSERT_EQUAL(ESPScheduler::kInvalidExecutorId, local.defaultWorkerExecutor()); + TEST_ASSERT_TRUE(local.begin()); + local.end(true); + worker.deinit(); +} - Schedule moonSchedule = Schedule::moonPhase(MoonPhaseName::LastQuarter, 2); - DateTime moonFrom = date.fromUtc(2024, 3, 25, 0, 0, 0); - DateTime moonDue{}; - TEST_ASSERT_TRUE(scheduler.computeNextOccurrence(moonSchedule, moonFrom, moonDue)); - uint32_t moonId = - scheduler.addJob(moonSchedule, SchedulerJobMode::Inline, &inlineCallback, nullptr); - TEST_ASSERT_NOT_EQUAL(0u, moonId); - scheduler.tick(moonDue); - TEST_ASSERT_EQUAL(2, inlineHits); +static void test_v1_compat_cleanup_prunes_canceled_jobs() { + ESPSchedulerV1Compat compat(date); + uint32_t jobId = compat.addJob(Schedule::dailyAtLocal(6, 0), SchedulerJobMode::Inline, &inlineCallback); + TEST_ASSERT_TRUE(jobId != 0); + + SchedulerV1JobInfo info{}; + TEST_ASSERT_TRUE(compat.getJobInfo(0, info)); + TEST_ASSERT_EQUAL(jobId, info.id); + + TEST_ASSERT_TRUE(compat.cancelJob(jobId)); + compat.cleanup(); + TEST_ASSERT_FALSE(compat.getJobInfo(0, info)); + compat.deinit(); +} + +static void test_end_wait_true_drains_manual_async_invocation() { + SchedulerConfig config = manualConfig(); + ESPScheduler local(date, config); + TEST_ASSERT_TRUE(local.begin()); + + JobOptions options{}; + options.dispatch = DispatchPolicy::Async; + SchedulerResult added = + local.addJobOnceUtc(date.fromUtc(2025, 1, 1, 6, 0, 0), options, &slowCallback, nullptr); + TEST_ASSERT_TRUE(added.ok()); + + local.tick(date.fromUtc(2025, 1, 1, 6, 0, 0)); + local.end(true, 3000); + TEST_ASSERT_EQUAL(1, slowHits); + TEST_ASSERT_FALSE(local.running()); +} + +static void test_end_wait_false_returns_without_drain() { + SchedulerConfig config = manualConfig(); + ESPScheduler local(date, config); + TEST_ASSERT_TRUE(local.begin()); + + JobOptions options{}; + options.dispatch = DispatchPolicy::Async; + SchedulerResult added = + local.addJobOnceUtc(date.fromUtc(2025, 1, 1, 6, 0, 0), options, &slowCallback, nullptr); + TEST_ASSERT_TRUE(added.ok()); + + local.tick(date.fromUtc(2025, 1, 1, 6, 0, 0)); + const uint32_t startedMs = millis(); + local.end(false, 10); + TEST_ASSERT_TRUE((millis() - startedMs) < 200); +} + +static void test_begin_fails_for_invalid_service_stack_size() { + SchedulerConfig config{}; + config.mode = SchedulerMode::Background; + config.service.taskStackSize = 1000; + ESPScheduler local(date, config); + TEST_ASSERT_FALSE(local.begin()); } void setUp() { - scheduler.cancelAll(); - scheduler.setMinValidUnixSeconds(ESPScheduler::kDefaultMinValidEpochSeconds); inlineHits = 0; + asyncHits = 0; + slowHits = 0; + scheduler.begin(); + scheduler.setMinValidUnixSeconds(ESPScheduler::kDefaultMinValidEpochSeconds); + scheduler.cancelAll(); } void tearDown() { + scheduler.end(true); } void setup() { @@ -358,24 +569,35 @@ void setup() { config.timeZone = "UTC0"; date.init(config); delay(2000); + UNITY_BEGIN(); + RUN_TEST(test_begin_is_idempotent_and_end_is_explicit); RUN_TEST(test_daily_at_local_next_same_day); RUN_TEST(test_daily_at_local_rolls_to_next_day); RUN_TEST(test_weekly_mask_advances_to_next_weekday); - RUN_TEST(test_weekly_zero_mask_defaults_to_any_day); RUN_TEST(test_dom_dow_or_logic_matches_either); RUN_TEST(test_inline_tick_runs_and_reschedules); - RUN_TEST(test_get_job_info_reports_next_run); - RUN_TEST(test_tick_waits_until_clock_valid); - RUN_TEST(test_psram_buffer_config_constructor_adds_inline_job); - RUN_TEST(test_deinit_is_idempotent_and_safe_when_uninitialized); - RUN_TEST(test_scheduler_reinitializes_after_deinit); + RUN_TEST(test_get_job_info_reports_next_run_by_job_id); + RUN_TEST(test_tick_waits_until_clock_valid_and_primes_once); + RUN_TEST(test_pause_resume_cancel_and_job_count); + RUN_TEST(test_slot_reuse_keeps_old_job_id_invalid); + RUN_TEST(test_executor_unavailable_is_reported); RUN_TEST(test_sunrise_next_occurrence_with_offsets); RUN_TEST(test_sunset_next_occurrence_with_offsets); RUN_TEST(test_moon_phase_name_last_quarter_next_occurrence); RUN_TEST(test_moon_illumination_crossing_and_reschedule); RUN_TEST(test_invalid_astronomical_schedule_validation); - RUN_TEST(test_tick_runs_sunrise_and_moon_schedules); + RUN_TEST(test_skip_if_running_behavior); + RUN_TEST(test_queue_one_behavior); + RUN_TEST(test_allow_parallel_behavior); + RUN_TEST(test_cancel_running_async_job_and_stale_completion_is_ignored); + RUN_TEST(test_background_async_runs_without_tick); + RUN_TEST(test_begin_fails_for_missing_builtin_espworker); + RUN_TEST(test_builtin_espworker_executor_id_available_when_configured); + RUN_TEST(test_end_wait_true_drains_manual_async_invocation); + RUN_TEST(test_end_wait_false_returns_without_drain); + RUN_TEST(test_begin_fails_for_invalid_service_stack_size); + RUN_TEST(test_v1_compat_cleanup_prunes_canceled_jobs); UNITY_END(); }