From dbab65bd4719c6b8b743c8f5713a5af3a837568e Mon Sep 17 00:00:00 2001 From: crispyberry Date: Mon, 9 Feb 2026 01:02:33 +0800 Subject: [PATCH] feat: add cron scheduled task service with tool_use integration Co-Authored-By: Claude Opus 4.6 --- main/CMakeLists.txt | 2 + main/agent/context_builder.c | 5 +- main/bus/message_bus.h | 1 + main/cron/cron_service.c | 410 +++++++++++++++++++++++++++++++++++ main/cron/cron_service.h | 63 ++++++ main/mimi.c | 5 + main/mimi_config.h | 5 + main/tools/tool_cron.c | 186 ++++++++++++++++ main/tools/tool_cron.h | 22 ++ main/tools/tool_registry.c | 47 +++- 10 files changed, 744 insertions(+), 2 deletions(-) create mode 100644 main/cron/cron_service.c create mode 100644 main/cron/cron_service.h create mode 100644 main/tools/tool_cron.c create mode 100644 main/tools/tool_cron.h diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index ba2684c..533533e 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -26,6 +26,8 @@ idf_component_register( "tools/tool_web_search.c" "tools/tool_get_time.c" "tools/tool_files.c" + "tools/tool_cron.c" + "cron/cron_service.c" INCLUDE_DIRS "." EMBED_FILES diff --git a/main/agent/context_builder.c b/main/agent/context_builder.c index 43469f7..787c230 100644 --- a/main/agent/context_builder.c +++ b/main/agent/context_builder.c @@ -43,7 +43,10 @@ esp_err_t context_build_system_prompt(char *buf, size_t size) "- read_file: Read a file from SPIFFS (path must start with /spiffs/).\n" "- write_file: Write/overwrite a file on SPIFFS.\n" "- edit_file: Find-and-replace edit a file on SPIFFS.\n" - "- list_dir: List files on SPIFFS, optionally filter by prefix.\n\n" + "- list_dir: List files on SPIFFS, optionally filter by prefix.\n" + "- cron_add: Schedule a recurring or one-shot task. The message will trigger an agent turn when the job fires.\n" + "- cron_list: List all scheduled cron jobs.\n" + "- cron_remove: Remove a scheduled cron job by ID.\n\n" "Use tools when needed. Provide your final answer as text after using tools.\n\n" "## Memory\n" "You have persistent memory stored on local flash:\n" diff --git a/main/bus/message_bus.h b/main/bus/message_bus.h index b460d18..78d40f4 100644 --- a/main/bus/message_bus.h +++ b/main/bus/message_bus.h @@ -8,6 +8,7 @@ #define MIMI_CHAN_TELEGRAM "telegram" #define MIMI_CHAN_WEBSOCKET "websocket" #define MIMI_CHAN_CLI "cli" +#define MIMI_CHAN_SYSTEM "system" /* Message types on the bus */ typedef struct { diff --git a/main/cron/cron_service.c b/main/cron/cron_service.c new file mode 100644 index 0000000..6dcab84 --- /dev/null +++ b/main/cron/cron_service.c @@ -0,0 +1,410 @@ +#include "cron/cron_service.h" +#include "mimi_config.h" +#include "bus/message_bus.h" + +#include +#include +#include +#include +#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; +} diff --git a/main/cron/cron_service.h b/main/cron/cron_service.h new file mode 100644 index 0000000..3d37752 --- /dev/null +++ b/main/cron/cron_service.h @@ -0,0 +1,63 @@ +#pragma once + +#include "esp_err.h" +#include +#include + +/* Schedule types */ +typedef enum { + CRON_KIND_EVERY = 0, /* Recurring interval in seconds */ + CRON_KIND_AT = 1, /* One-shot at unix timestamp */ +} cron_kind_t; + +/* A single cron job */ +typedef struct { + char id[9]; /* 8-char hex ID + null */ + char name[32]; + bool enabled; + cron_kind_t kind; + uint32_t interval_s; /* For EVERY: interval in seconds */ + int64_t at_epoch; /* For AT: unix timestamp */ + char message[256]; /* Message to inject into inbound queue */ + char channel[16]; /* Reply channel (default "system") */ + char chat_id[32]; /* Reply chat_id (default "cron") */ + int64_t last_run; /* Last run epoch */ + int64_t next_run; /* Next run epoch */ + bool delete_after_run; /* Remove job after firing (for AT jobs) */ +} cron_job_t; + +/** + * Initialize the cron service. Loads jobs from SPIFFS. + */ +esp_err_t cron_service_init(void); + +/** + * Start the cron timer. Call after WiFi is connected and time is synced. + */ +esp_err_t cron_service_start(void); + +/** + * Stop the cron timer. + */ +void cron_service_stop(void); + +/** + * Add a new cron job. + * @param job Pointer to job struct (id will be generated) + * @return ESP_OK on success, ESP_ERR_NO_MEM if max jobs reached + */ +esp_err_t cron_add_job(cron_job_t *job); + +/** + * Remove a cron job by ID. + * @param job_id 8-char job ID + * @return ESP_OK on success, ESP_ERR_NOT_FOUND if not found + */ +esp_err_t cron_remove_job(const char *job_id); + +/** + * List all cron jobs. + * @param jobs Output array of job pointers + * @param count Output: number of jobs + */ +void cron_list_jobs(const cron_job_t **jobs, int *count); diff --git a/main/mimi.c b/main/mimi.c index 0658055..60f4a4e 100644 --- a/main/mimi.c +++ b/main/mimi.c @@ -26,6 +26,7 @@ #include "ui/config_screen.h" #include "imu/imu_manager.h" #include "rgb/rgb.h" +#include "cron/cron_service.h" static const char *TAG = "mimi"; @@ -77,6 +78,8 @@ static void outbound_dispatch_task(void *arg) telegram_send_message(msg.chat_id, msg.content); } else if (strcmp(msg.channel, MIMI_CHAN_WEBSOCKET) == 0) { ws_server_send(msg.chat_id, msg.content); + } else if (strcmp(msg.channel, MIMI_CHAN_SYSTEM) == 0) { + ESP_LOGI(TAG, "System message [%s]: %.128s", msg.chat_id, msg.content); } else { ESP_LOGW(TAG, "Unknown channel: %s", msg.channel); } @@ -124,6 +127,7 @@ void app_main(void) ESP_ERROR_CHECK(telegram_bot_init()); ESP_ERROR_CHECK(llm_proxy_init()); ESP_ERROR_CHECK(tool_registry_init()); + ESP_ERROR_CHECK(cron_service_init()); ESP_ERROR_CHECK(agent_loop_init()); /* Start Serial CLI first (works without WiFi) */ @@ -141,6 +145,7 @@ void app_main(void) /* Start network-dependent services */ ESP_ERROR_CHECK(telegram_bot_start()); ESP_ERROR_CHECK(agent_loop_start()); + cron_service_start(); ESP_ERROR_CHECK(ws_server_start()); /* Outbound dispatch task */ diff --git a/main/mimi_config.h b/main/mimi_config.h index 079ecc0..6f90dfe 100644 --- a/main/mimi_config.h +++ b/main/mimi_config.h @@ -84,6 +84,11 @@ #define MIMI_CONTEXT_BUF_SIZE (16 * 1024) #define MIMI_SESSION_MAX_MSGS 20 +/* Cron Service */ +#define MIMI_CRON_FILE "/spiffs/config/cron.json" +#define MIMI_CRON_CHECK_INTERVAL_MS (30 * 1000) +#define MIMI_CRON_MAX_JOBS 8 + /* WebSocket Gateway */ #define MIMI_WS_PORT 18789 #define MIMI_WS_MAX_CLIENTS 4 diff --git a/main/tools/tool_cron.c b/main/tools/tool_cron.c new file mode 100644 index 0000000..dfb0a6d --- /dev/null +++ b/main/tools/tool_cron.c @@ -0,0 +1,186 @@ +#include "tools/tool_cron.h" +#include "cron/cron_service.h" + +#include +#include +#include "esp_log.h" +#include "cJSON.h" + +static const char *TAG = "tool_cron"; + +/* ── cron_add ─────────────────────────────────────────────────── */ + +esp_err_t tool_cron_add_execute(const char *input_json, char *output, size_t output_size) +{ + cJSON *root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + + const char *name = cJSON_GetStringValue(cJSON_GetObjectItem(root, "name")); + const char *schedule_type = cJSON_GetStringValue(cJSON_GetObjectItem(root, "schedule_type")); + const char *message = cJSON_GetStringValue(cJSON_GetObjectItem(root, "message")); + + if (!name || !schedule_type || !message) { + snprintf(output, output_size, "Error: missing required fields (name, schedule_type, message)"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + if (strlen(message) == 0) { + snprintf(output, output_size, "Error: message must not be empty"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + cron_job_t job; + memset(&job, 0, sizeof(job)); + strncpy(job.name, name, sizeof(job.name) - 1); + strncpy(job.message, message, sizeof(job.message) - 1); + + /* Optional channel and chat_id */ + const char *channel = cJSON_GetStringValue(cJSON_GetObjectItem(root, "channel")); + const char *chat_id = cJSON_GetStringValue(cJSON_GetObjectItem(root, "chat_id")); + if (channel) strncpy(job.channel, channel, sizeof(job.channel) - 1); + if (chat_id) strncpy(job.chat_id, chat_id, sizeof(job.chat_id) - 1); + + if (strcmp(schedule_type, "every") == 0) { + job.kind = CRON_KIND_EVERY; + cJSON *interval = cJSON_GetObjectItem(root, "interval_s"); + if (!interval || !cJSON_IsNumber(interval) || interval->valuedouble <= 0) { + snprintf(output, output_size, "Error: 'every' schedule requires positive 'interval_s'"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + job.interval_s = (uint32_t)interval->valuedouble; + job.delete_after_run = false; + } else if (strcmp(schedule_type, "at") == 0) { + job.kind = CRON_KIND_AT; + cJSON *at_epoch = cJSON_GetObjectItem(root, "at_epoch"); + if (!at_epoch || !cJSON_IsNumber(at_epoch)) { + snprintf(output, output_size, "Error: 'at' schedule requires 'at_epoch' (unix timestamp)"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + job.at_epoch = (int64_t)at_epoch->valuedouble; + + /* Check if already in the past */ + time_t now = time(NULL); + if (job.at_epoch <= now) { + snprintf(output, output_size, "Error: at_epoch %lld is in the past (now=%lld)", + (long long)job.at_epoch, (long long)now); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + /* Default: delete one-shot jobs after run */ + cJSON *delete_j = cJSON_GetObjectItem(root, "delete_after_run"); + job.delete_after_run = delete_j ? cJSON_IsTrue(delete_j) : true; + } else { + snprintf(output, output_size, "Error: schedule_type must be 'every' or 'at'"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + cJSON_Delete(root); + + esp_err_t err = cron_add_job(&job); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: failed to add job (%s)", esp_err_to_name(err)); + return err; + } + + /* Format success response */ + if (job.kind == CRON_KIND_EVERY) { + snprintf(output, output_size, + "OK: Added recurring job '%s' (id=%s), runs every %lu seconds. Next run at epoch %lld.", + job.name, job.id, (unsigned long)job.interval_s, (long long)job.next_run); + } else { + snprintf(output, output_size, + "OK: Added one-shot job '%s' (id=%s), fires at epoch %lld.%s", + job.name, job.id, (long long)job.at_epoch, + job.delete_after_run ? " Will be deleted after firing." : ""); + } + + ESP_LOGI(TAG, "cron_add: %s", output); + return ESP_OK; +} + +/* ── cron_list ────────────────────────────────────────────────── */ + +esp_err_t tool_cron_list_execute(const char *input_json, char *output, size_t output_size) +{ + (void)input_json; + + const cron_job_t *jobs; + int count; + cron_list_jobs(&jobs, &count); + + if (count == 0) { + snprintf(output, output_size, "No cron jobs scheduled."); + return ESP_OK; + } + + size_t off = 0; + off += snprintf(output + off, output_size - off, + "Scheduled jobs (%d):\n", count); + + for (int i = 0; i < count && off < output_size - 1; i++) { + const cron_job_t *j = &jobs[i]; + + if (j->kind == CRON_KIND_EVERY) { + off += snprintf(output + off, output_size - off, + " %d. [%s] \"%s\" — every %lus, %s, next=%lld, last=%lld, ch=%s:%s\n", + i + 1, j->id, j->name, + (unsigned long)j->interval_s, + j->enabled ? "enabled" : "disabled", + (long long)j->next_run, (long long)j->last_run, + j->channel, j->chat_id); + } else { + off += snprintf(output + off, output_size - off, + " %d. [%s] \"%s\" — at %lld, %s, last=%lld, ch=%s:%s%s\n", + i + 1, j->id, j->name, + (long long)j->at_epoch, + j->enabled ? "enabled" : "disabled", + (long long)j->last_run, + j->channel, j->chat_id, + j->delete_after_run ? " (auto-delete)" : ""); + } + } + + ESP_LOGI(TAG, "cron_list: %d jobs", count); + return ESP_OK; +} + +/* ── cron_remove ──────────────────────────────────────────────── */ + +esp_err_t tool_cron_remove_execute(const char *input_json, char *output, size_t output_size) +{ + cJSON *root = cJSON_Parse(input_json); + if (!root) { + snprintf(output, output_size, "Error: invalid JSON input"); + return ESP_ERR_INVALID_ARG; + } + + const char *job_id = cJSON_GetStringValue(cJSON_GetObjectItem(root, "job_id")); + if (!job_id || strlen(job_id) == 0) { + snprintf(output, output_size, "Error: missing 'job_id' field"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + esp_err_t err = cron_remove_job(job_id); + cJSON_Delete(root); + + if (err == ESP_OK) { + snprintf(output, output_size, "OK: Removed cron job %s", job_id); + } else if (err == ESP_ERR_NOT_FOUND) { + snprintf(output, output_size, "Error: job '%s' not found", job_id); + } else { + snprintf(output, output_size, "Error: failed to remove job (%s)", esp_err_to_name(err)); + } + + ESP_LOGI(TAG, "cron_remove: %s -> %s", job_id, esp_err_to_name(err)); + return err; +} diff --git a/main/tools/tool_cron.h b/main/tools/tool_cron.h new file mode 100644 index 0000000..b99eb8e --- /dev/null +++ b/main/tools/tool_cron.h @@ -0,0 +1,22 @@ +#pragma once + +#include "esp_err.h" +#include + +/** + * Add a scheduled cron job. + * Input JSON: { name, schedule_type ("every"/"at"), interval_s, at_epoch, message, channel?, chat_id? } + */ +esp_err_t tool_cron_add_execute(const char *input_json, char *output, size_t output_size); + +/** + * List all scheduled cron jobs. + * Input JSON: {} (no required fields) + */ +esp_err_t tool_cron_list_execute(const char *input_json, char *output, size_t output_size); + +/** + * Remove a scheduled cron job by ID. + * Input JSON: { job_id } + */ +esp_err_t tool_cron_remove_execute(const char *input_json, char *output, size_t output_size); diff --git a/main/tools/tool_registry.c b/main/tools/tool_registry.c index 3f4d892..c277ed1 100644 --- a/main/tools/tool_registry.c +++ b/main/tools/tool_registry.c @@ -2,6 +2,7 @@ #include "tools/tool_web_search.h" #include "tools/tool_get_time.h" #include "tools/tool_files.h" +#include "tools/tool_cron.h" #include #include "esp_log.h" @@ -9,7 +10,7 @@ static const char *TAG = "tools"; -#define MAX_TOOLS 8 +#define MAX_TOOLS 12 static mimi_tool_t s_tools[MAX_TOOLS]; static int s_tool_count = 0; @@ -130,6 +131,50 @@ esp_err_t tool_registry_init(void) }; register_tool(&ld); + /* Register cron_add */ + mimi_tool_t ca = { + .name = "cron_add", + .description = "Schedule a recurring or one-shot task. The message will trigger an agent turn when the job fires.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{" + "\"name\":{\"type\":\"string\",\"description\":\"Short name for the job\"}," + "\"schedule_type\":{\"type\":\"string\",\"description\":\"'every' for recurring interval or 'at' for one-shot at a unix timestamp\"}," + "\"interval_s\":{\"type\":\"integer\",\"description\":\"Interval in seconds (required for 'every')\"}," + "\"at_epoch\":{\"type\":\"integer\",\"description\":\"Unix timestamp to fire at (required for 'at')\"}," + "\"message\":{\"type\":\"string\",\"description\":\"Message to inject when the job fires, triggering an agent turn\"}," + "\"channel\":{\"type\":\"string\",\"description\":\"Optional reply channel (e.g. 'telegram'). Defaults to 'system'\"}," + "\"chat_id\":{\"type\":\"string\",\"description\":\"Optional reply chat_id. Defaults to 'cron'\"}" + "}," + "\"required\":[\"name\",\"schedule_type\",\"message\"]}", + .execute = tool_cron_add_execute, + }; + register_tool(&ca); + + /* Register cron_list */ + mimi_tool_t cl = { + .name = "cron_list", + .description = "List all scheduled cron jobs with their status, schedule, and IDs.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{}," + "\"required\":[]}", + .execute = tool_cron_list_execute, + }; + register_tool(&cl); + + /* Register cron_remove */ + mimi_tool_t cr = { + .name = "cron_remove", + .description = "Remove a scheduled cron job by its ID.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{\"job_id\":{\"type\":\"string\",\"description\":\"The 8-character job ID to remove\"}}," + "\"required\":[\"job_id\"]}", + .execute = tool_cron_remove_execute, + }; + register_tool(&cr); + build_tools_json(); ESP_LOGI(TAG, "Tool registry initialized");