bubao/esp_cron

0.0.8

Latest
uploaded 2 hours ago
ESP-IDF cron expression component

Readme

# ESP-Cron: CRON-like Task Scheduler for ESP-IDF

> [中文版](./README_zh.md)

## Overview

ESP-Cron is a lightweight, CRON-style task scheduler for ESP-IDF, built on `esp_timer` and FreeRTOS. It parses standard 6-field CRON expressions (seconds, minutes, hours, day, month, weekday) and schedules callbacks with second-level precision.

**Key characteristics:**
- Scheduling computes next trigger times from the system clock (`time()`)
- `esp_timer` merely waits for the computed delay — it does **not** determine "what time is it"
- Thread-safe with reference counting (safe `cron_job_destroy` even while jobs are queued)
- Smart low-power: timer stops automatically when no jobs are scheduled
- Per-job runner task — no shared thread pool, no task-pool complexity

**You must synchronize system time (SNTP) before creating jobs.** Otherwise, schedules are computed against the 1970 epoch.

---

## What's Inside

| Document | Purpose |
|----------|---------|
| **This README** | Usage guide, API reference, configuration, gotchas |
| [docs/DESIGN.md](./docs/DESIGN.md) | Architecture, thread-safety model, lifecycle contracts, what NOT to do |

---

## Project Structure

```
esp_cron/
├── esp_cron.c                  # Core scheduler (esp_timer + FreeRTOS queue + refcount)
├── include/
│   ├── esp_cron.h              # Public header (include this one)
│   └── cron.h                  # Internal API + cron_job struct definition
├── library/
│   ├── ccronexpr/              # Third-party CRON expression parser
│   └── jobs/                   # Sorted linked list management
├── Kconfig                     # Build-time configuration
├── examples/
│   └── esp_cron_example/       # Complete runnable example
├── test/                       # Unity test cases (test_cron.c)
├── test_apps/                  # Runnable test app (see "Running the Tests")
└── docs/
    └── DESIGN.md               # Architecture & lifecycle contracts (start here)
```

---

## Prerequisites

1. **System time must be valid.** Use SNTP (or RTC on chips with battery backup) to set the clock before calling `cron_job_create`.

2. **Correct boot order:**
   ```text
   NVS init
     ↓
   Wi-Fi connect
     ↓
   SNTP sync  ← wait here until time is valid
     ↓
   cron_job_create(...)
     ↓
   cron_start()
   ```

---

## Quick Start

### Minimal Example (no SNTP — only for testing; time will be wrong in production)

```c
#include "esp_cron.h"
#include "esp_log.h"
#include <time.h>

static const char *TAG = "cron_example";

void my_callback(cron_job *job) {
    ESP_LOGI(TAG, "Job triggered! data=%p", job->data);
}

void app_main(void) {
    // For testing only — in production, sync SNTP first
    struct timeval tv = { .tv_sec = 1530000000 }; // June 2018
    settimeofday(&tv, NULL);

    // Create a job that fires every second
    cron_job *job = cron_job_create("* * * * * *", my_callback, (void *)42);
    if (!job) {
        ESP_LOGE(TAG, "Failed to create job");
        return;
    }

    // Start the scheduler
    cron_start();

    // Keep main alive (scheduler runs in background tasks)
    while (1) {
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}
```

### Production Example (with SNTP)

```c
#include "esp_cron.h"
#include "esp_sntp.h"
#include "esp_log.h"
#include <time.h>

static const char *TAG = "cron_app";

void water_pump_callback(cron_job *job) {
    ESP_LOGI(TAG, "Turning pump ON");
    // ... control GPIO / pump ...
}

void sensor_read_callback(cron_job *job) {
    ESP_LOGI(TAG, "Reading soil moisture sensor");
    // ... read ADC / I2C ...
}

static void wait_for_time_sync(void) {
    ESP_LOGI(TAG, "Waiting for SNTP sync...");
    sntp_setoperatingmode(SNTP_OPMODE_POLL);
    sntp_setservername(0, "pool.ntp.org");
    sntp_init();

    int retry = 0;
    const int max_retry = 30;
    while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < max_retry) {
        vTaskDelay(pdMS_TO_TICKS(1000));
        retry++;
    }
    if (retry >= max_retry) {
        ESP_LOGW(TAG, "SNTP sync timed out, scheduling may be incorrect");
    } else {
        ESP_LOGI(TAG, "Time synced successfully");
    }
}

void app_main(void) {
    // 1. Set timezone (affects CRON expression evaluation)
    setenv("TZ", "CST-8", 1);
    tzset();

    // 2. Wait for SNTP — must complete before creating jobs
    wait_for_time_sync();

    // 3. Create jobs AFTER time is valid
    // "0 0 8 * * *" = every day at 08:00
    cron_job *pump_job = cron_job_create("0 0 8 * * *", water_pump_callback, NULL);

    // "0 0 18 * * *" = every day at 18:00
    cron_job *evening_job = cron_job_create("0 0 18 * * *", water_pump_callback, NULL);

    // "*/30 * * * * *" = every 30 seconds
    cron_job *sensor_job = cron_job_create("*/30 * * * * *", sensor_read_callback, NULL);

    // 4. Start scheduler
    cron_start();

    ESP_LOGI(TAG, "Scheduler started with %d jobs",
             pump_job && evening_job && sensor_job ? 3 : 0);

    while (1) {
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}
```

---

## CRON Expression Format

6 fields with second-level precision:

```
┌────────────── second (0 - 59)
| ┌───────────── minute (0 - 59)
| │ ┌───────────── hour (0 - 23)
| │ │ ┌───────────── day of month (1 - 31)
| │ │ │ ┌───────────── month (1 - 12)
| │ │ │ │ ┌───────────── day of week (0 - 6, Sunday=0)
| │ │ │ │ │
* * * * * *
```

Common patterns:

| Expression | Meaning |
|------------|---------|
| `* * * * * *` | Every second |
| `0 * * * * *` | Every minute (at second 0) |
| `*/10 * * * * *` | Every 10 seconds |
| `0 0 8 * * *` | Every day at 08:00 |
| `0 30 7,19 * * *` | Every day at 07:30 and 19:30 |
| `0 0 8 * * 1-5` | Weekdays at 08:00 |

---

## API Reference

### Task Management

```c
// Create and schedule a new job. Returns NULL on failure.
// The job is automatically added to the scheduler.
cron_job *cron_job_create(const char *schedule,
                          cron_job_callback callback,
                          void *data);

// Destroy a job (removes from schedule, releases memory).
// Thread-safe: safe to call even while jobs are queued.
int cron_job_destroy(cron_job *job);

// Remove all jobs from the schedule.
// Per contract, performs no deallocation — the caller owns each job's memory
// and must cron_job_destroy() every job it created.
int cron_job_clear_all();
```

### Scheduler Control

```c
// Start the scheduler (creates worker task + esp_timer).
// Must be called after at least one job is created.
int cron_start();

// Stop the scheduler (stops timer, drains queue, clears all jobs).
int cron_stop();
```

### Manual Scheduling

```c
// Re-insert a job into the schedule (use after dynamic expression change).
int cron_job_schedule(cron_job *job);

// Remove a job from schedule without freeing it.
int cron_job_unschedule(cron_job *job);
```

### Dynamic Expression Update

```c
// Parse a new CRON expression into an existing job.
// You MUST call cron_job_schedule() afterward to apply the change.
int cron_job_load_expression(cron_job *job, const char *schedule);

// Check if expression was loaded successfully.
int cron_job_has_loaded(cron_job *job);
```

Usage:

```c
// Change a job's schedule
cron_job_unschedule(my_job);
cron_job_load_expression(my_job, "0 0 9 * * *");  // Now at 09:00
cron_job_schedule(my_job);
```

### Time Resync

```c
// Reschedule ALL jobs based on current system time.
// Call this after SNTP sync completes or time jumps.
int cron_job_reschedule_all();
```

### Status

```c
// Seconds until next job triggers.
time_t cron_job_seconds_until_next_execution();
```

---

## Kconfig Configuration

In `menuconfig` (ESP-IDF):

| Option | Default | Description |
|--------|---------|-------------|
| `CONFIG_ESP_CRON_ENABLE` | `y` | Enable/disable the component |
| `CONFIG_ESP_CRON_WORKER_STACK_SIZE` | `4096` | Stack size for worker and runner tasks (bytes) |
| `CONFIG_ESP_CRON_QUEUE_DEPTH` | `10` | Max executions waiting to run (capacity of the pending queue) |
| `CONFIG_ESP_CRON_MAX_DUE_JOBS` | `16` | Max jobs processed per single timer callback |
| `CONFIG_ESP_CRON_MIN_DELAY_US` | `1000` | Minimum timer delay (microseconds) |
| `CONFIG_ESP_CRON_ENABLE_STATS` | `n` | Track execution/cancel/queue-full/resync/skip counters (~40 bytes) |

> `cancelled` counts only executions cancelled by `destroy` (queued but not yet started). Triggers skipped because **the same job was already running** are tracked separately in `skipped_running`.

---

## Running the Tests

The tests live in `test/` (Unity source) and a ready-to-run app in `test_apps/`:

```bash
cd components/esp_cron/test_apps
idf.py set-target esp32s2   # match your board
idf.py flash monitor        # runs all 12 test cases on the device
```

> Note: cron schedules depend on the system clock. The tests call `settimeofday` directly — no SNTP needed.

Covered: list add/remove/boundary cases (remove-first, remove-last, single-node), cron expression scheduling, `clear_all`, `reschedule_all`, `start/stop` idempotency, `cancelled` initialization, expression modification + reschedule.

### 24h soak test (recommended)

To validate long-term stability (heap, task churn, skip/cancel counters), run several jobs of different periods while keeping Wi-Fi + SNTP active, periodically doing dynamic `schedule`/`unschedule`/`destroy`/`create` and `stop/start`, with callbacks simulating 1–3s work. Watch `free heap`, `minimum free heap`, `largest free block`, and the stats counters (enable `CONFIG_ESP_CRON_ENABLE_STATS`).

---

## Time Synchronization

### Why It Matters

The scheduler computes `next_execution` via `cron_next(expression, time())`. If `time()` returns `1970-01-01` (no sync), then `next_execution` will be computed relative to 1970 — the job will fire at the wrong moment.

### Correct Boot Order

```
NVS → Wi-Fi → SNTP (wait) → cron_job_create → cron_start
```

### Time Jumps at Runtime

If the system time jumps (e.g., SNTP correction after initial bad sync), the scheduler auto-detects jumps >1 second and recomputes all schedules.

**You should also register an SNTP sync callback** to reschedule immediately (rather than waiting for the next timer tick):

```c
#include "esp_sntp.h"

void sntp_sync_cb(struct timeval *tv) {
    cron_job_reschedule_all();  // rebuild all next_execution values
}

// Register before sntp_init()
sntp_set_time_sync_notification_cb(sntp_sync_cb);
```

### Timezone

CRON expressions are evaluated in **local time** (local time is enabled by default). Set `TZ` **before** creating jobs:

```c
setenv("TZ", "CST-8", 1);
tzset();
```

---

## Thread Safety

This component uses two mutexes internally:

- **`s_mutex`**: Serializes all linked-list and queue operations. Protects against concurrent `timer_cb`, `cron_job_destroy`, `cron_job_schedule`, and `cron_start/stop`.

- **`s_ref_mutex`**: Protects the reference counter on each `cron_job`. Ensures safe deallocation when a job is being executed by a callback while another thread calls `cron_job_destroy`.

**Safe patterns:**
- Call `cron_job_destroy()` from any task — even if the job is currently queued
- Call `cron_start()` / `cron_stop()` from any task — calls are serialized
- Create and destroy jobs from different tasks concurrently

**Reference counting behavior:**
- `cron_job_create`: refs = 1 (caller owns)
- Queue send: refs += 1 (queue owns, grabbed inside the scheduler lock)
- Worker finishes callback: refs -= 1; if refs == 0 → `free(job)`
- `cron_job_destroy`: refs -= 1; if refs == 0 → `free(job)`

This means `cron_job_destroy()` never blocks waiting for a running callback to finish — the job is freed automatically when the last reference is released.

## Lifecycle Semantics

### destroy cancels a queued job (B semantics)

Once `cron_job_destroy(job)` returns, the job is marked `cancelled`. A job that was already in the queue but has not started running **will not** execute its callback — the worker rewinds and releases it silently.

**`cron_job_destroy()` is terminal.** After destroy, the job handle must not be reused — `cron_job_schedule()`, `cron_job_load_expression()`, `cron_job_unschedule()` reject a cancelled job. Create a new job instead.

### destroy inside a callback is safe

```c
void cb(cron_job *job) {
    cron_job_destroy(job); // safe: runner still holds a ref
    // ... code after destroy also OK, job is freed after cb returns
}
```

The runner owns the final reference; the job is freed only after the callback returns and the runner releases it.

### No overlap of the same job (in_flight guard)

Once a job is **successfully queued** (its runner may not have started yet), its `in_flight` flag is set. If the next scheduled moment arrives while that job is still executing or still in the queue, that trigger is **skipped** (never queued). This prevents concurrent runners for the same job — critical for sequences like `pump ON → delay → pump OFF`. If enqueue fails (queue full), `in_flight` is restored so the job is never permanently skipped.

### start/stop are idempotent

| Pattern | Result |
|---------|--------|
| `cron_start(); cron_start();` | Second call returns `0` (already running, no duplicate timer/worker created) |
| `cron_stop(); cron_stop();` | Second call returns `-1` (not running) |
| `cron_start(); cron_stop(); cron_start();` | Restarts cleanly |

`cron_stop()`:
- Stops the timer (no new queue events)
- **Cooperative shutdown**: sends a stop sentinel to the worker, waits for its ack, then proceeds — the worker is never force-deleted mid-operation
- Drains the queue and `cron_job_clear_all()` (all jobs removed from the schedule; their memory is released by the caller's `cron_job_destroy`, since `clear_all` performs no deallocation per its contract)

Note: `cron_stop()` does **not** wait for callbacks already in-flight. Queued/running jobs finish on their runners (refcount guarantees safe teardown); un-consumed queue references are drained and released without leaking.

> `MAX_DUE_JOBS` is the **number processed per timer callback**, `QUEUE_DEPTH` is the **pending-execution queue capacity** — neither is a limit on how many cron jobs you may create. So `16` does not mean "max 16 jobs". If more jobs are due in one moment than `MAX_DUE_JOBS`, the excess **stays in the schedule** and is processed by the next timer fire (`MIN_DELAY` clamp) — jobs are never dropped, only slightly batched. If more jobs are due than `QUEUE_DEPTH`, the excess is dropped from that cycle (`queue full` warning) but the offending jobs are rescheduled for their next matching moment.

### Queue full behavior

If the FreeRTOS queue is full when a job fires
(`CONFIG_ESP_CRON_QUEUE_DEPTH` exceeded), the event is dropped, a warning is printed, the job's `in_flight` flag is **restored** (so it fires again on the next matching moment), and the queue reference is released. **Jobs are never silently leaked and never permanently stuck skipped**.

### Missed executions

If a device wakes after a scheduled moment (e.g. Deep Sleep wakes at 08:10 but a job was scheduled for 08:00), the job is **not** run retroactively — the scheduler recomputes from the current time and waits for the next matching moment. This is deliberate: catching up on missed irrigation cycles could be dangerous.

### Callback contract

Callbacks run on a per-job runner task. Keep them short — send an event to a business worker instead of blocking (no `vTaskDelay(10000)`, HTTP requests, or script execution in the callback). Safe to call these APIs from inside a callback:
- `cron_job_schedule(job)` / `cron_job_unschedule(job)`
- `cron_job_load_expression(job, ...)` / `cron_job_destroy(job)`
- `cron_job_reschedule_all()`

**Do NOT call `cron_stop()` / `cron_start()` from inside a callback.** They manipulate the scheduler's worker and timer lifecycle; calling them from a runner can deadlock or tear down the infrastructure mid-execution.

---

## Limitations & Anti-Patterns

**Not supported (by design):**

1. **Deep Sleep recovery.** Jobs are in-memory objects; Deep Sleep destroys all RAM. Persist the schedule to NVS and re-create jobs on wake.

2. **Sub-second precision.** Minimum granularity is 1 second.

3. **Single scheduler instance.** Only one `cron_start()` / `cron_stop()` cycle at a time.

4. **Catching up missed executions.** If the device wakes after a scheduled moment (e.g. Deep Sleep wakes at 08:10 but a job was for 08:00), the job is **not** run retroactively — the scheduler waits for the next matching moment. This prevents dangerous cascading replays (e.g. irrigation pump double-trigger).

**Do NOT use esp_cron for:**

5. **Long-running callbacks.** Callbacks run on a per-job runner task. If your callback blocks for minutes (HTTP, heavy computation, `vTaskDelay`), the job's next trigger will be skipped (`in_flight` guard — no concurrent runners for the same job). Keep callbacks short — send an event to a dedicated worker task instead.

6. **Calling `cron_stop()` / `cron_start()` from inside a callback.** This manipulates the scheduler's worker and timer lifecycle; calling them from a runner can deadlock or tear down infrastructure mid-execution. (All other scheduler APIs — schedule, unschedule, destroy, load_expression, reschedule_all — are safe inside callbacks.)

7. **`day-of-month` AND `day-of-week` simultaneously.** The underlying `ccronexpr` parser follows POSIX: if both are specified, the job triggers when **either** matches (logical OR, not AND).

---

## Acknowledgments

- [esp_cron](https://github.com/DavidMora/esp_cron) by David Mora Rodriguez
- [ccronexpr](https://github.com/staticlibs/ccronexpr) — CRON expression parser

## License

[Apache License 2.0](./LICENSE.txt)

Links

Supports all targets

To add this component to your project, run:

idf.py add-dependency "bubao/esp_cron^0.0.8"

download archive

Stats

  • Archive size
    Archive size ~ 1.98 MB
  • Downloaded in total
    Downloaded in total 553 times
  • Weekly Downloads Weekly Downloads (All Versions)
  • Downloaded this version
    This version: 0 times

Badge

bubao/esp_cron version: 0.0.8
|