Merge pull request #4 from memovai/cron
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -45,5 +45,4 @@ nanobot/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
references/
|
||||
references/
|
||||
|
||||
@@ -26,6 +26,9 @@ 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"
|
||||
"heartbeat/heartbeat.c"
|
||||
INCLUDE_DIRS
|
||||
"."
|
||||
EMBED_FILES
|
||||
|
||||
@@ -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"
|
||||
@@ -55,7 +58,12 @@ esp_err_t context_build_system_prompt(char *buf, size_t size)
|
||||
"- Always read_file MEMORY.md before writing, so you can edit_file to update without losing existing content.\n"
|
||||
"- Use get_current_time to know today's date before writing daily notes.\n"
|
||||
"- Keep MEMORY.md concise and organized — summarize, don't dump raw conversation.\n"
|
||||
"- You should proactively save memory without being asked. If the user tells you their name, preferences, or important facts, persist them immediately.\n");
|
||||
"- You should proactively save memory without being asked. If the user tells you their name, preferences, or important facts, persist them immediately.\n\n"
|
||||
"## Heartbeat\n"
|
||||
"The file /spiffs/config/HEARTBEAT.md contains periodic tasks.\n"
|
||||
"When triggered by heartbeat, read the file and execute any pending tasks.\n"
|
||||
"If nothing needs attention, reply with just: HEARTBEAT_OK\n"
|
||||
"You can also write to HEARTBEAT.md to schedule tasks for yourself.\n");
|
||||
|
||||
/* Bootstrap files */
|
||||
off = append_file(buf, size, off, MIMI_SOUL_FILE, "Personality");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
#include "memory/session_mgr.h"
|
||||
#include "proxy/http_proxy.h"
|
||||
#include "tools/tool_web_search.h"
|
||||
#include "tools/tool_registry.h"
|
||||
#include "cron/cron_service.h"
|
||||
#include "heartbeat/heartbeat.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
@@ -316,6 +319,54 @@ static int cmd_config_reset(int argc, char **argv)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- heartbeat_trigger command --- */
|
||||
static int cmd_heartbeat_trigger(int argc, char **argv)
|
||||
{
|
||||
printf("Checking HEARTBEAT.md...\n");
|
||||
if (heartbeat_trigger()) {
|
||||
printf("Heartbeat: agent prompted with pending tasks.\n");
|
||||
} else {
|
||||
printf("Heartbeat: no actionable tasks found.\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- cron_start command --- */
|
||||
static int cmd_cron_start(int argc, char **argv)
|
||||
{
|
||||
esp_err_t err = cron_service_start();
|
||||
if (err == ESP_OK) {
|
||||
printf("Cron service started.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
printf("Failed to start cron service: %s\n", esp_err_to_name(err));
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int cmd_tool_exec(int argc, char **argv)
|
||||
{
|
||||
if (argc < 2) {
|
||||
printf("Usage: tool_exec <name> [json]\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *tool_name = argv[1];
|
||||
const char *input_json = (argc >= 3) ? argv[2] : "{}";
|
||||
|
||||
char *output = calloc(1, 4096);
|
||||
if (!output) {
|
||||
printf("Out of memory.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_err_t err = tool_registry_execute(tool_name, input_json, output, 4096);
|
||||
printf("tool_exec status: %s\n", esp_err_to_name(err));
|
||||
printf("%s\n", output[0] ? output : "(empty)");
|
||||
free(output);
|
||||
return (err == ESP_OK) ? 0 : 1;
|
||||
}
|
||||
|
||||
/* --- restart command --- */
|
||||
static int cmd_restart(int argc, char **argv)
|
||||
{
|
||||
@@ -505,6 +556,30 @@ esp_err_t serial_cli_init(void)
|
||||
};
|
||||
esp_console_cmd_register(&config_reset_cmd);
|
||||
|
||||
/* heartbeat_trigger */
|
||||
esp_console_cmd_t heartbeat_cmd = {
|
||||
.command = "heartbeat_trigger",
|
||||
.help = "Manually trigger a heartbeat check",
|
||||
.func = &cmd_heartbeat_trigger,
|
||||
};
|
||||
esp_console_cmd_register(&heartbeat_cmd);
|
||||
|
||||
/* cron_start */
|
||||
esp_console_cmd_t cron_start_cmd = {
|
||||
.command = "cron_start",
|
||||
.help = "Start cron scheduler timer now",
|
||||
.func = &cmd_cron_start,
|
||||
};
|
||||
esp_console_cmd_register(&cron_start_cmd);
|
||||
|
||||
/* tool_exec */
|
||||
esp_console_cmd_t tool_exec_cmd = {
|
||||
.command = "tool_exec",
|
||||
.help = "Execute a registered tool: tool_exec <name> '{...json...}'",
|
||||
.func = &cmd_tool_exec,
|
||||
};
|
||||
esp_console_cmd_register(&tool_exec_cmd);
|
||||
|
||||
/* restart */
|
||||
esp_console_cmd_t restart_cmd = {
|
||||
.command = "restart",
|
||||
|
||||
408
main/cron/cron_service.c
Normal file
408
main/cron/cron_service.c
Normal file
@@ -0,0 +1,408 @@
|
||||
#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/task.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 TaskHandle_t s_cron_task = 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;
|
||||
}
|
||||
|
||||
/* ── Due-job processing ───────────────────────────────────────── */
|
||||
|
||||
static void cron_process_due_jobs(void)
|
||||
{
|
||||
time_t now = time(NULL);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
static void cron_task_main(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
while (1) {
|
||||
vTaskDelay(pdMS_TO_TICKS(MIMI_CRON_CHECK_INTERVAL_MS));
|
||||
cron_process_due_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_task) {
|
||||
ESP_LOGW(TAG, "Cron task 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BaseType_t ok = xTaskCreate(
|
||||
cron_task_main,
|
||||
"cron",
|
||||
4096,
|
||||
NULL,
|
||||
4,
|
||||
&s_cron_task
|
||||
);
|
||||
if (ok != pdPASS || !s_cron_task) {
|
||||
ESP_LOGE(TAG, "Failed to create cron task");
|
||||
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_task) {
|
||||
vTaskDelete(s_cron_task);
|
||||
s_cron_task = 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;
|
||||
}
|
||||
63
main/cron/cron_service.h
Normal file
63
main/cron/cron_service.h
Normal file
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* 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);
|
||||
164
main/heartbeat/heartbeat.c
Normal file
164
main/heartbeat/heartbeat.c
Normal file
@@ -0,0 +1,164 @@
|
||||
#include "heartbeat/heartbeat.h"
|
||||
#include "mimi_config.h"
|
||||
#include "bus/message_bus.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <ctype.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/timers.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
static const char *TAG = "heartbeat";
|
||||
|
||||
#define HEARTBEAT_PROMPT \
|
||||
"Read " MIMI_HEARTBEAT_FILE " and follow any instructions or tasks listed there. " \
|
||||
"If nothing needs attention, reply with just: HEARTBEAT_OK"
|
||||
|
||||
static TimerHandle_t s_heartbeat_timer = NULL;
|
||||
|
||||
/* ── Content check ────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Check if HEARTBEAT.md has actionable content.
|
||||
* Returns true if any line is NOT:
|
||||
* - empty / whitespace-only
|
||||
* - a markdown header (starts with #)
|
||||
* - a completed checkbox (- [x] or * [x])
|
||||
*/
|
||||
static bool heartbeat_has_tasks(void)
|
||||
{
|
||||
FILE *f = fopen(MIMI_HEARTBEAT_FILE, "r");
|
||||
if (!f) {
|
||||
return false;
|
||||
}
|
||||
|
||||
char line[256];
|
||||
bool found_task = false;
|
||||
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
/* Skip leading whitespace */
|
||||
const char *p = line;
|
||||
while (*p && isspace((unsigned char)*p)) {
|
||||
p++;
|
||||
}
|
||||
|
||||
/* Skip empty lines */
|
||||
if (*p == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Skip markdown headers */
|
||||
if (*p == '#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Skip completed checkboxes: "- [x]" or "* [x]" */
|
||||
if ((*p == '-' || *p == '*') && *(p + 1) == ' ' && *(p + 2) == '[') {
|
||||
char mark = *(p + 3);
|
||||
if ((mark == 'x' || mark == 'X') && *(p + 4) == ']') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* Found an actionable line */
|
||||
found_task = true;
|
||||
break;
|
||||
}
|
||||
|
||||
fclose(f);
|
||||
return found_task;
|
||||
}
|
||||
|
||||
/* ── Send heartbeat to agent ──────────────────────────────────── */
|
||||
|
||||
static bool heartbeat_send(void)
|
||||
{
|
||||
if (!heartbeat_has_tasks()) {
|
||||
ESP_LOGD(TAG, "No actionable tasks in HEARTBEAT.md");
|
||||
return false;
|
||||
}
|
||||
|
||||
mimi_msg_t msg;
|
||||
memset(&msg, 0, sizeof(msg));
|
||||
strncpy(msg.channel, MIMI_CHAN_SYSTEM, sizeof(msg.channel) - 1);
|
||||
strncpy(msg.chat_id, "heartbeat", sizeof(msg.chat_id) - 1);
|
||||
msg.content = strdup(HEARTBEAT_PROMPT);
|
||||
|
||||
if (!msg.content) {
|
||||
ESP_LOGE(TAG, "Failed to allocate heartbeat prompt");
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_err_t err = message_bus_push_inbound(&msg);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to push heartbeat message: %s", esp_err_to_name(err));
|
||||
free(msg.content);
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Triggered agent check");
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── Timer callback ───────────────────────────────────────────── */
|
||||
|
||||
static void heartbeat_timer_callback(TimerHandle_t xTimer)
|
||||
{
|
||||
(void)xTimer;
|
||||
heartbeat_send();
|
||||
}
|
||||
|
||||
/* ── Public API ───────────────────────────────────────────────── */
|
||||
|
||||
esp_err_t heartbeat_init(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "Heartbeat service initialized (file: %s, interval: %ds)",
|
||||
MIMI_HEARTBEAT_FILE, MIMI_HEARTBEAT_INTERVAL_MS / 1000);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t heartbeat_start(void)
|
||||
{
|
||||
if (s_heartbeat_timer) {
|
||||
ESP_LOGW(TAG, "Heartbeat timer already running");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
s_heartbeat_timer = xTimerCreate(
|
||||
"heartbeat",
|
||||
pdMS_TO_TICKS(MIMI_HEARTBEAT_INTERVAL_MS),
|
||||
pdTRUE, /* auto-reload */
|
||||
NULL,
|
||||
heartbeat_timer_callback
|
||||
);
|
||||
|
||||
if (!s_heartbeat_timer) {
|
||||
ESP_LOGE(TAG, "Failed to create heartbeat timer");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (xTimerStart(s_heartbeat_timer, pdMS_TO_TICKS(1000)) != pdPASS) {
|
||||
ESP_LOGE(TAG, "Failed to start heartbeat timer");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Heartbeat started (every %d min)", MIMI_HEARTBEAT_INTERVAL_MS / 60000);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void heartbeat_stop(void)
|
||||
{
|
||||
if (s_heartbeat_timer) {
|
||||
xTimerStop(s_heartbeat_timer, pdMS_TO_TICKS(1000));
|
||||
xTimerDelete(s_heartbeat_timer, pdMS_TO_TICKS(1000));
|
||||
s_heartbeat_timer = NULL;
|
||||
ESP_LOGI(TAG, "Heartbeat stopped");
|
||||
}
|
||||
}
|
||||
|
||||
bool heartbeat_trigger(void)
|
||||
{
|
||||
return heartbeat_send();
|
||||
}
|
||||
26
main/heartbeat/heartbeat.h
Normal file
26
main/heartbeat/heartbeat.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
/**
|
||||
* Initialize the heartbeat service (logs ready state).
|
||||
*/
|
||||
esp_err_t heartbeat_init(void);
|
||||
|
||||
/**
|
||||
* Start the heartbeat timer. Checks HEARTBEAT.md periodically
|
||||
* and sends a prompt to the agent if actionable tasks are found.
|
||||
*/
|
||||
esp_err_t heartbeat_start(void);
|
||||
|
||||
/**
|
||||
* Stop and delete the heartbeat timer.
|
||||
*/
|
||||
void heartbeat_stop(void);
|
||||
|
||||
/**
|
||||
* Manually trigger a heartbeat check (for CLI testing).
|
||||
* Returns true if the agent was prompted, false if no tasks found.
|
||||
*/
|
||||
bool heartbeat_trigger(void);
|
||||
@@ -13,9 +13,13 @@
|
||||
|
||||
static const char *TAG = "llm";
|
||||
|
||||
static char s_api_key[128] = {0};
|
||||
static char s_model[64] = MIMI_LLM_DEFAULT_MODEL;
|
||||
static char s_provider[16] = MIMI_LLM_PROVIDER_DEFAULT;
|
||||
#define LLM_API_KEY_MAX_LEN 256
|
||||
#define LLM_MODEL_MAX_LEN 64
|
||||
#define LLM_PROVIDER_MAX_LEN 16
|
||||
|
||||
static char s_api_key[LLM_API_KEY_MAX_LEN] = {0};
|
||||
static char s_model[LLM_MODEL_MAX_LEN] = MIMI_LLM_DEFAULT_MODEL;
|
||||
static char s_provider[LLM_PROVIDER_MAX_LEN] = MIMI_LLM_PROVIDER_DEFAULT;
|
||||
|
||||
static void safe_copy(char *dst, size_t dst_size, const char *src)
|
||||
{
|
||||
@@ -118,20 +122,23 @@ esp_err_t llm_proxy_init(void)
|
||||
/* NVS overrides take highest priority (set via CLI) */
|
||||
nvs_handle_t nvs;
|
||||
if (nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs) == ESP_OK) {
|
||||
char tmp[128] = {0};
|
||||
char tmp[LLM_API_KEY_MAX_LEN] = {0};
|
||||
size_t len = sizeof(tmp);
|
||||
if (nvs_get_str(nvs, MIMI_NVS_KEY_API_KEY, tmp, &len) == ESP_OK && tmp[0]) {
|
||||
safe_copy(s_api_key, sizeof(s_api_key), tmp);
|
||||
}
|
||||
len = sizeof(tmp);
|
||||
memset(tmp, 0, sizeof(tmp));
|
||||
if (nvs_get_str(nvs, MIMI_NVS_KEY_MODEL, tmp, &len) == ESP_OK && tmp[0]) {
|
||||
safe_copy(s_model, sizeof(s_model), tmp);
|
||||
}
|
||||
len = sizeof(tmp);
|
||||
memset(tmp, 0, sizeof(tmp));
|
||||
if (nvs_get_str(nvs, MIMI_NVS_KEY_PROVIDER, tmp, &len) == ESP_OK && tmp[0]) {
|
||||
safe_copy(s_provider, sizeof(s_provider), tmp);
|
||||
|
||||
char model_tmp[LLM_MODEL_MAX_LEN] = {0};
|
||||
len = sizeof(model_tmp);
|
||||
if (nvs_get_str(nvs, MIMI_NVS_KEY_MODEL, model_tmp, &len) == ESP_OK && model_tmp[0]) {
|
||||
safe_copy(s_model, sizeof(s_model), model_tmp);
|
||||
}
|
||||
|
||||
char provider_tmp[LLM_PROVIDER_MAX_LEN] = {0};
|
||||
len = sizeof(provider_tmp);
|
||||
if (nvs_get_str(nvs, MIMI_NVS_KEY_PROVIDER, provider_tmp, &len) == ESP_OK && provider_tmp[0]) {
|
||||
safe_copy(s_provider, sizeof(s_provider), provider_tmp);
|
||||
}
|
||||
nvs_close(nvs);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
#include "ui/config_screen.h"
|
||||
#include "imu/imu_manager.h"
|
||||
#include "rgb/rgb.h"
|
||||
#include "cron/cron_service.h"
|
||||
#include "heartbeat/heartbeat.h"
|
||||
|
||||
static const char *TAG = "mimi";
|
||||
|
||||
@@ -77,6 +79,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 +128,8 @@ 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(heartbeat_init());
|
||||
ESP_ERROR_CHECK(agent_loop_init());
|
||||
|
||||
/* Start Serial CLI first (works without WiFi) */
|
||||
@@ -141,6 +147,8 @@ void app_main(void)
|
||||
/* Start network-dependent services */
|
||||
ESP_ERROR_CHECK(telegram_bot_start());
|
||||
ESP_ERROR_CHECK(agent_loop_start());
|
||||
cron_service_start();
|
||||
heartbeat_start();
|
||||
ESP_ERROR_CHECK(ws_server_start());
|
||||
|
||||
/* Outbound dispatch task */
|
||||
|
||||
@@ -84,6 +84,15 @@
|
||||
#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
|
||||
|
||||
/* Heartbeat */
|
||||
#define MIMI_HEARTBEAT_FILE "/spiffs/config/HEARTBEAT.md"
|
||||
#define MIMI_HEARTBEAT_INTERVAL_MS (30 * 60 * 1000)
|
||||
|
||||
/* WebSocket Gateway */
|
||||
#define MIMI_WS_PORT 18789
|
||||
#define MIMI_WS_MAX_CLIENTS 4
|
||||
|
||||
189
main/tools/tool_cron.c
Normal file
189
main/tools/tool_cron.c
Normal file
@@ -0,0 +1,189 @@
|
||||
#include "tools/tool_cron.h"
|
||||
#include "cron/cron_service.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#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;
|
||||
}
|
||||
|
||||
char job_id_copy[16] = {0};
|
||||
strncpy(job_id_copy, job_id, sizeof(job_id_copy) - 1);
|
||||
|
||||
esp_err_t err = cron_remove_job(job_id_copy);
|
||||
cJSON_Delete(root);
|
||||
|
||||
if (err == ESP_OK) {
|
||||
snprintf(output, output_size, "OK: Removed cron job %s", job_id_copy);
|
||||
} else if (err == ESP_ERR_NOT_FOUND) {
|
||||
snprintf(output, output_size, "Error: job '%s' not found", job_id_copy);
|
||||
} 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_copy, esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
22
main/tools/tool_cron.h
Normal file
22
main/tools/tool_cron.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stddef.h>
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include "esp_log.h"
|
||||
|
||||
@@ -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 <string.h>
|
||||
#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");
|
||||
|
||||
Reference in New Issue
Block a user