411 lines
12 KiB
C
411 lines
12 KiB
C
#include "cron/cron_service.h"
|
|
#include "mimi_config.h"
|
|
#include "bus/message_bus.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/timers.h"
|
|
#include "esp_log.h"
|
|
#include "esp_random.h"
|
|
#include "cJSON.h"
|
|
|
|
static const char *TAG = "cron";
|
|
|
|
#define MAX_CRON_JOBS MIMI_CRON_MAX_JOBS
|
|
|
|
static cron_job_t s_jobs[MAX_CRON_JOBS];
|
|
static int s_job_count = 0;
|
|
static TimerHandle_t s_cron_timer = NULL;
|
|
|
|
/* ── Persistence ──────────────────────────────────────────────── */
|
|
|
|
static void cron_generate_id(char *id_buf)
|
|
{
|
|
uint32_t r = esp_random();
|
|
snprintf(id_buf, 9, "%08x", (unsigned int)r);
|
|
}
|
|
|
|
static esp_err_t cron_load_jobs(void)
|
|
{
|
|
FILE *f = fopen(MIMI_CRON_FILE, "r");
|
|
if (!f) {
|
|
ESP_LOGI(TAG, "No cron file found, starting fresh");
|
|
s_job_count = 0;
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* Read entire file */
|
|
fseek(f, 0, SEEK_END);
|
|
long fsize = ftell(f);
|
|
fseek(f, 0, SEEK_SET);
|
|
|
|
if (fsize <= 0 || fsize > 8192) {
|
|
ESP_LOGW(TAG, "Cron file invalid size: %ld", fsize);
|
|
fclose(f);
|
|
s_job_count = 0;
|
|
return ESP_OK;
|
|
}
|
|
|
|
char *buf = malloc(fsize + 1);
|
|
if (!buf) {
|
|
fclose(f);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
size_t n = fread(buf, 1, fsize, f);
|
|
buf[n] = '\0';
|
|
fclose(f);
|
|
|
|
/* Parse JSON */
|
|
cJSON *root = cJSON_Parse(buf);
|
|
free(buf);
|
|
|
|
if (!root) {
|
|
ESP_LOGW(TAG, "Failed to parse cron JSON");
|
|
s_job_count = 0;
|
|
return ESP_OK;
|
|
}
|
|
|
|
cJSON *jobs_arr = cJSON_GetObjectItem(root, "jobs");
|
|
if (!jobs_arr || !cJSON_IsArray(jobs_arr)) {
|
|
cJSON_Delete(root);
|
|
s_job_count = 0;
|
|
return ESP_OK;
|
|
}
|
|
|
|
s_job_count = 0;
|
|
cJSON *item;
|
|
cJSON_ArrayForEach(item, jobs_arr) {
|
|
if (s_job_count >= MAX_CRON_JOBS) break;
|
|
|
|
cron_job_t *job = &s_jobs[s_job_count];
|
|
memset(job, 0, sizeof(cron_job_t));
|
|
|
|
const char *id = cJSON_GetStringValue(cJSON_GetObjectItem(item, "id"));
|
|
const char *name = cJSON_GetStringValue(cJSON_GetObjectItem(item, "name"));
|
|
const char *kind_str = cJSON_GetStringValue(cJSON_GetObjectItem(item, "kind"));
|
|
const char *message = cJSON_GetStringValue(cJSON_GetObjectItem(item, "message"));
|
|
const char *channel = cJSON_GetStringValue(cJSON_GetObjectItem(item, "channel"));
|
|
const char *chat_id = cJSON_GetStringValue(cJSON_GetObjectItem(item, "chat_id"));
|
|
|
|
if (!id || !name || !kind_str || !message) continue;
|
|
|
|
strncpy(job->id, id, sizeof(job->id) - 1);
|
|
strncpy(job->name, name, sizeof(job->name) - 1);
|
|
strncpy(job->message, message, sizeof(job->message) - 1);
|
|
strncpy(job->channel, channel ? channel : MIMI_CHAN_SYSTEM,
|
|
sizeof(job->channel) - 1);
|
|
strncpy(job->chat_id, chat_id ? chat_id : "cron",
|
|
sizeof(job->chat_id) - 1);
|
|
|
|
cJSON *enabled_j = cJSON_GetObjectItem(item, "enabled");
|
|
job->enabled = enabled_j ? cJSON_IsTrue(enabled_j) : true;
|
|
|
|
cJSON *delete_j = cJSON_GetObjectItem(item, "delete_after_run");
|
|
job->delete_after_run = delete_j ? cJSON_IsTrue(delete_j) : false;
|
|
|
|
if (strcmp(kind_str, "every") == 0) {
|
|
job->kind = CRON_KIND_EVERY;
|
|
cJSON *interval = cJSON_GetObjectItem(item, "interval_s");
|
|
job->interval_s = (interval && cJSON_IsNumber(interval))
|
|
? (uint32_t)interval->valuedouble : 0;
|
|
} else if (strcmp(kind_str, "at") == 0) {
|
|
job->kind = CRON_KIND_AT;
|
|
cJSON *at_epoch = cJSON_GetObjectItem(item, "at_epoch");
|
|
job->at_epoch = (at_epoch && cJSON_IsNumber(at_epoch))
|
|
? (int64_t)at_epoch->valuedouble : 0;
|
|
} else {
|
|
continue; /* Unknown kind, skip */
|
|
}
|
|
|
|
cJSON *last_run = cJSON_GetObjectItem(item, "last_run");
|
|
job->last_run = (last_run && cJSON_IsNumber(last_run))
|
|
? (int64_t)last_run->valuedouble : 0;
|
|
|
|
cJSON *next_run = cJSON_GetObjectItem(item, "next_run");
|
|
job->next_run = (next_run && cJSON_IsNumber(next_run))
|
|
? (int64_t)next_run->valuedouble : 0;
|
|
|
|
s_job_count++;
|
|
}
|
|
|
|
cJSON_Delete(root);
|
|
ESP_LOGI(TAG, "Loaded %d cron jobs", s_job_count);
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t cron_save_jobs(void)
|
|
{
|
|
cJSON *root = cJSON_CreateObject();
|
|
cJSON *jobs_arr = cJSON_CreateArray();
|
|
|
|
for (int i = 0; i < s_job_count; i++) {
|
|
cron_job_t *job = &s_jobs[i];
|
|
cJSON *item = cJSON_CreateObject();
|
|
|
|
cJSON_AddStringToObject(item, "id", job->id);
|
|
cJSON_AddStringToObject(item, "name", job->name);
|
|
cJSON_AddBoolToObject(item, "enabled", job->enabled);
|
|
cJSON_AddStringToObject(item, "kind",
|
|
job->kind == CRON_KIND_EVERY ? "every" : "at");
|
|
|
|
if (job->kind == CRON_KIND_EVERY) {
|
|
cJSON_AddNumberToObject(item, "interval_s", job->interval_s);
|
|
} else {
|
|
cJSON_AddNumberToObject(item, "at_epoch", (double)job->at_epoch);
|
|
}
|
|
|
|
cJSON_AddStringToObject(item, "message", job->message);
|
|
cJSON_AddStringToObject(item, "channel", job->channel);
|
|
cJSON_AddStringToObject(item, "chat_id", job->chat_id);
|
|
cJSON_AddNumberToObject(item, "last_run", (double)job->last_run);
|
|
cJSON_AddNumberToObject(item, "next_run", (double)job->next_run);
|
|
cJSON_AddBoolToObject(item, "delete_after_run", job->delete_after_run);
|
|
|
|
cJSON_AddItemToArray(jobs_arr, item);
|
|
}
|
|
|
|
cJSON_AddItemToObject(root, "jobs", jobs_arr);
|
|
|
|
char *json_str = cJSON_Print(root);
|
|
cJSON_Delete(root);
|
|
|
|
if (!json_str) {
|
|
ESP_LOGE(TAG, "Failed to serialize cron jobs");
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
FILE *f = fopen(MIMI_CRON_FILE, "w");
|
|
if (!f) {
|
|
ESP_LOGE(TAG, "Failed to open %s for writing", MIMI_CRON_FILE);
|
|
free(json_str);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
size_t len = strlen(json_str);
|
|
size_t written = fwrite(json_str, 1, len, f);
|
|
fclose(f);
|
|
free(json_str);
|
|
|
|
if (written != len) {
|
|
ESP_LOGE(TAG, "Cron save incomplete: %d/%d bytes", (int)written, (int)len);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "Saved %d cron jobs to %s", s_job_count, MIMI_CRON_FILE);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* ── Timer callback ───────────────────────────────────────────── */
|
|
|
|
static void cron_timer_callback(TimerHandle_t xTimer)
|
|
{
|
|
(void)xTimer;
|
|
|
|
time_t now = time(NULL);
|
|
if (now < 1700000000) {
|
|
/* System time not set yet (before ~2023), skip */
|
|
return;
|
|
}
|
|
|
|
bool changed = false;
|
|
|
|
for (int i = 0; i < s_job_count; i++) {
|
|
cron_job_t *job = &s_jobs[i];
|
|
if (!job->enabled) continue;
|
|
if (job->next_run <= 0) continue;
|
|
if (job->next_run > now) continue;
|
|
|
|
/* Job is due — fire it */
|
|
ESP_LOGI(TAG, "Cron job firing: %s (%s)", job->name, job->id);
|
|
|
|
/* Push message to inbound queue */
|
|
mimi_msg_t msg;
|
|
memset(&msg, 0, sizeof(msg));
|
|
strncpy(msg.channel, job->channel, sizeof(msg.channel) - 1);
|
|
strncpy(msg.chat_id, job->chat_id, sizeof(msg.chat_id) - 1);
|
|
msg.content = strdup(job->message);
|
|
|
|
if (msg.content) {
|
|
esp_err_t err = message_bus_push_inbound(&msg);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGW(TAG, "Failed to push cron message: %s", esp_err_to_name(err));
|
|
free(msg.content);
|
|
}
|
|
}
|
|
|
|
/* Update state */
|
|
job->last_run = now;
|
|
|
|
if (job->kind == CRON_KIND_AT) {
|
|
/* One-shot: disable or delete */
|
|
if (job->delete_after_run) {
|
|
/* Remove by shifting array */
|
|
ESP_LOGI(TAG, "Deleting one-shot job: %s", job->name);
|
|
for (int j = i; j < s_job_count - 1; j++) {
|
|
s_jobs[j] = s_jobs[j + 1];
|
|
}
|
|
s_job_count--;
|
|
i--; /* Re-check this index */
|
|
} else {
|
|
job->enabled = false;
|
|
job->next_run = 0;
|
|
}
|
|
} else {
|
|
/* Recurring: compute next run */
|
|
job->next_run = now + job->interval_s;
|
|
}
|
|
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
cron_save_jobs();
|
|
}
|
|
}
|
|
|
|
/* ── Compute initial next_run for a new job ───────────────────── */
|
|
|
|
static void compute_initial_next_run(cron_job_t *job)
|
|
{
|
|
time_t now = time(NULL);
|
|
|
|
if (job->kind == CRON_KIND_EVERY) {
|
|
job->next_run = now + job->interval_s;
|
|
} else if (job->kind == CRON_KIND_AT) {
|
|
if (job->at_epoch > now) {
|
|
job->next_run = job->at_epoch;
|
|
} else {
|
|
/* Already in the past */
|
|
job->next_run = 0;
|
|
job->enabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── Public API ───────────────────────────────────────────────── */
|
|
|
|
esp_err_t cron_service_init(void)
|
|
{
|
|
return cron_load_jobs();
|
|
}
|
|
|
|
esp_err_t cron_service_start(void)
|
|
{
|
|
if (s_cron_timer) {
|
|
ESP_LOGW(TAG, "Cron timer already running");
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* Recompute next_run for all enabled jobs that don't have one */
|
|
time_t now = time(NULL);
|
|
for (int i = 0; i < s_job_count; i++) {
|
|
cron_job_t *job = &s_jobs[i];
|
|
if (job->enabled && job->next_run <= 0) {
|
|
if (job->kind == CRON_KIND_EVERY) {
|
|
job->next_run = now + job->interval_s;
|
|
} else if (job->kind == CRON_KIND_AT && job->at_epoch > now) {
|
|
job->next_run = job->at_epoch;
|
|
}
|
|
}
|
|
}
|
|
|
|
s_cron_timer = xTimerCreate(
|
|
"cron",
|
|
pdMS_TO_TICKS(MIMI_CRON_CHECK_INTERVAL_MS),
|
|
pdTRUE, /* auto-reload */
|
|
NULL,
|
|
cron_timer_callback
|
|
);
|
|
|
|
if (!s_cron_timer) {
|
|
ESP_LOGE(TAG, "Failed to create cron timer");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (xTimerStart(s_cron_timer, pdMS_TO_TICKS(1000)) != pdPASS) {
|
|
ESP_LOGE(TAG, "Failed to start cron timer");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "Cron service started (%d jobs, check every %ds)",
|
|
s_job_count, MIMI_CRON_CHECK_INTERVAL_MS / 1000);
|
|
return ESP_OK;
|
|
}
|
|
|
|
void cron_service_stop(void)
|
|
{
|
|
if (s_cron_timer) {
|
|
xTimerStop(s_cron_timer, pdMS_TO_TICKS(1000));
|
|
xTimerDelete(s_cron_timer, pdMS_TO_TICKS(1000));
|
|
s_cron_timer = NULL;
|
|
ESP_LOGI(TAG, "Cron service stopped");
|
|
}
|
|
}
|
|
|
|
esp_err_t cron_add_job(cron_job_t *job)
|
|
{
|
|
if (s_job_count >= MAX_CRON_JOBS) {
|
|
ESP_LOGW(TAG, "Max cron jobs reached (%d)", MAX_CRON_JOBS);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
/* Generate ID */
|
|
cron_generate_id(job->id);
|
|
|
|
/* Set defaults for channel/chat_id if empty */
|
|
if (job->channel[0] == '\0') {
|
|
strncpy(job->channel, MIMI_CHAN_SYSTEM, sizeof(job->channel) - 1);
|
|
}
|
|
if (job->chat_id[0] == '\0') {
|
|
strncpy(job->chat_id, "cron", sizeof(job->chat_id) - 1);
|
|
}
|
|
|
|
/* Compute initial next_run */
|
|
job->enabled = true;
|
|
job->last_run = 0;
|
|
compute_initial_next_run(job);
|
|
|
|
/* Copy into static array */
|
|
s_jobs[s_job_count] = *job;
|
|
s_job_count++;
|
|
|
|
cron_save_jobs();
|
|
|
|
ESP_LOGI(TAG, "Added cron job: %s (%s) kind=%s next_run=%lld",
|
|
job->name, job->id,
|
|
job->kind == CRON_KIND_EVERY ? "every" : "at",
|
|
(long long)job->next_run);
|
|
return ESP_OK;
|
|
}
|
|
|
|
esp_err_t cron_remove_job(const char *job_id)
|
|
{
|
|
for (int i = 0; i < s_job_count; i++) {
|
|
if (strcmp(s_jobs[i].id, job_id) == 0) {
|
|
ESP_LOGI(TAG, "Removing cron job: %s (%s)", s_jobs[i].name, job_id);
|
|
|
|
/* Shift remaining jobs down */
|
|
for (int j = i; j < s_job_count - 1; j++) {
|
|
s_jobs[j] = s_jobs[j + 1];
|
|
}
|
|
s_job_count--;
|
|
|
|
cron_save_jobs();
|
|
return ESP_OK;
|
|
}
|
|
}
|
|
|
|
ESP_LOGW(TAG, "Cron job not found: %s", job_id);
|
|
return ESP_ERR_NOT_FOUND;
|
|
}
|
|
|
|
void cron_list_jobs(const cron_job_t **jobs, int *count)
|
|
{
|
|
*jobs = s_jobs;
|
|
*count = s_job_count;
|
|
}
|