diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 484c8bd..8f71fcc 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -20,6 +20,8 @@ idf_component_register( "tools/tool_web_search.c" "tools/tool_get_time.c" "tools/tool_files.c" + "tools/tool_gpio.c" + "tools/gpio_policy.c" "skills/skill_loader.c" "onboard/wifi_onboard.c" INCLUDE_DIRS @@ -27,5 +29,5 @@ idf_component_register( REQUIRES nvs_flash esp_wifi esp_netif esp_http_client esp_http_server esp_https_ota esp_event json spiffs console vfs app_update esp-tls - esp_timer esp_websocket_client + esp_timer esp_websocket_client esp_driver_gpio ) diff --git a/main/agent/context_builder.c b/main/agent/context_builder.c index 5f8d503..870b2ac 100644 --- a/main/agent/context_builder.c +++ b/main/agent/context_builder.c @@ -46,8 +46,15 @@ esp_err_t context_build_system_prompt(char *buf, size_t size) "- list_dir: List files, 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" + "- cron_remove: Remove a scheduled cron job by ID.\n" + "- gpio_write: Set a GPIO pin HIGH or LOW. Use for controlling LEDs, relays, and digital outputs.\n" + "- gpio_read: Read a single GPIO pin state (HIGH or LOW). Use for checking switches, buttons, sensors.\n" + "- gpio_read_all: Read all allowed GPIO pins at once. Good for getting a full status overview.\n\n" "When using cron_add for Telegram delivery, always set channel='telegram' and a valid numeric chat_id.\n\n" + "## GPIO\n" + "You can control hardware GPIO pins on the ESP32-S3. Use gpio_read to check switch/sensor states " + "(digital input confirmation), and gpio_write to control outputs. Pin range is validated by policy — " + "only allowed pins can be accessed. When asked about switch states or digital I/O, use these tools.\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/mimi_config.h b/main/mimi_config.h index fbdbb03..f205c54 100644 --- a/main/mimi_config.h +++ b/main/mimi_config.h @@ -117,6 +117,9 @@ #define MIMI_HEARTBEAT_FILE MIMI_SPIFFS_BASE "/HEARTBEAT.md" #define MIMI_HEARTBEAT_INTERVAL_MS (30 * 60 * 1000) +/* GPIO */ +#define MIMI_GPIO_CONFIG_SECTION 1 /* enable GPIO tools */ + /* Skills */ #define MIMI_SKILLS_PREFIX MIMI_SPIFFS_BASE "/skills/" diff --git a/main/tools/gpio_policy.c b/main/tools/gpio_policy.c new file mode 100644 index 0000000..5c12d30 --- /dev/null +++ b/main/tools/gpio_policy.c @@ -0,0 +1,123 @@ +#include "tools/gpio_policy.h" + +#include "driver/gpio.h" + +#include +#include +#include + +#ifndef GPIO_IS_VALID_GPIO +#define GPIO_IS_VALID_GPIO(pin) ((pin) >= 0) +#endif + +static bool pin_in_allowlist(int pin, const char *csv) +{ + const char *cursor; + + if (!csv || csv[0] == '\0') { + return false; + } + + cursor = csv; + while (*cursor != '\0') { + char *endptr = NULL; + long value; + + while (*cursor == ' ' || *cursor == '\t' || *cursor == ',') { + cursor++; + } + if (*cursor == '\0') { + break; + } + + value = strtol(cursor, &endptr, 10); + if (endptr == cursor) { + while (*cursor != '\0' && *cursor != ',') { + cursor++; + } + continue; + } + + if ((int)value == pin) { + return true; + } + cursor = endptr; + } + + return false; +} + +static bool pin_is_allowed_impl(int pin, + const char *allowlist_csv, + int min_pin, + int max_pin, + bool block_esp32_flash_pins, + bool block_esp32s3_usb_pins) +{ + bool in_policy; + + if (pin < 0) { + return false; + } + + /* Block ESP32 flash/PSRAM pins (GPIO 6-11) */ + if (block_esp32_flash_pins && pin >= 6 && pin <= 11) { + return false; + } + + /* USB Serial/JTAG uses GPIO19/20 on ESP32-S3 */ + if (block_esp32s3_usb_pins && (pin == 19 || pin == 20)) { + return false; + } + + if (allowlist_csv && allowlist_csv[0] != '\0') { + in_policy = pin_in_allowlist(pin, allowlist_csv); + } else { + in_policy = pin >= min_pin && pin <= max_pin; + } + + if (!in_policy) { + return false; + } + + return GPIO_IS_VALID_GPIO((gpio_num_t)pin); +} + +bool gpio_policy_pin_is_allowed(int pin) +{ +#if defined(CONFIG_IDF_TARGET_ESP32) + return pin_is_allowed_impl(pin, MIMI_GPIO_ALLOWED_CSV, + MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN, true, false); +#elif defined(CONFIG_IDF_TARGET_ESP32S3) + return pin_is_allowed_impl(pin, MIMI_GPIO_ALLOWED_CSV, + MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN, false, true); +#else + return pin_is_allowed_impl(pin, MIMI_GPIO_ALLOWED_CSV, + MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN, false, false); +#endif +} + +bool gpio_policy_pin_forbidden_hint(int pin, char *result, size_t result_len) +{ +#if defined(CONFIG_IDF_TARGET_ESP32) + if (pin >= 6 && pin <= 11) { + snprintf(result, result_len, + "Error: pin %d is reserved for ESP32 flash/PSRAM (GPIO6-11); choose a different pin", + pin); + return true; + } +#elif defined(CONFIG_IDF_TARGET_ESP32S3) + if (pin == 19 || pin == 20) { + snprintf(result, result_len, + "Error: pin %d is reserved for ESP32-S3 USB Serial/JTAG (GPIO19/20); choose a different pin", + pin); + return true; + } +#else + (void)pin; + (void)result; + (void)result_len; +#endif + + return false; +} diff --git a/main/tools/gpio_policy.h b/main/tools/gpio_policy.h new file mode 100644 index 0000000..dc7d690 --- /dev/null +++ b/main/tools/gpio_policy.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +/* GPIO defaults for ESP32-S3-LCD-1.47B safe user-accessible pins */ +#define MIMI_GPIO_MIN_PIN 1 +#define MIMI_GPIO_MAX_PIN 21 +#define MIMI_GPIO_ALLOWED_CSV "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,21,38,46" + +/** + * Check if a pin is allowed for user GPIO operations. + * Validates against the allowlist or default range, and blocks + * pins reserved for flash/PSRAM on ESP32. + */ +bool gpio_policy_pin_is_allowed(int pin); + +/** + * Write a human-readable hint if the pin is forbidden for a known reason. + * Returns true if a hint was written (and the caller should return the error). + */ +bool gpio_policy_pin_forbidden_hint(int pin, char *result, size_t result_len); diff --git a/main/tools/tool_gpio.c b/main/tools/tool_gpio.c new file mode 100644 index 0000000..1e43b1b --- /dev/null +++ b/main/tools/tool_gpio.c @@ -0,0 +1,194 @@ +#include "tools/tool_gpio.h" +#include "tools/gpio_policy.h" +#include "mimi_config.h" + +#include "driver/gpio.h" +#include "esp_log.h" +#include "cJSON.h" + +#include +#include + +static const char *TAG = "tool_gpio"; + +esp_err_t tool_gpio_init(void) +{ + ESP_LOGI(TAG, "GPIO tool initialized (pin range %d-%d)", + MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN); + return ESP_OK; +} + +esp_err_t tool_gpio_write_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; + } + + cJSON *pin_obj = cJSON_GetObjectItem(root, "pin"); + cJSON *state_obj = cJSON_GetObjectItem(root, "state"); + + if (!cJSON_IsNumber(pin_obj)) { + snprintf(output, output_size, "Error: 'pin' required (integer)"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + if (!cJSON_IsNumber(state_obj)) { + snprintf(output, output_size, "Error: 'state' required (0 or 1)"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + int pin = (int)pin_obj->valuedouble; + int state = (int)state_obj->valuedouble; + + if (!gpio_policy_pin_is_allowed(pin)) { + if (gpio_policy_pin_forbidden_hint(pin, output, output_size)) { + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + if (MIMI_GPIO_ALLOWED_CSV[0] != '\0') { + snprintf(output, output_size, "Error: pin %d is not in allowed list", pin); + } else { + snprintf(output, output_size, "Error: pin must be %d-%d", + MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN); + } + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + if (gpio_set_direction(pin, GPIO_MODE_INPUT_OUTPUT) != ESP_OK || + gpio_set_level(pin, state ? 1 : 0) != ESP_OK) { + snprintf(output, output_size, "Error: failed to configure/write pin %d", pin); + cJSON_Delete(root); + return ESP_FAIL; + } + + snprintf(output, output_size, "Pin %d set to %s", pin, state ? "HIGH" : "LOW"); + ESP_LOGI(TAG, "gpio_write: pin %d -> %s", pin, state ? "HIGH" : "LOW"); + + cJSON_Delete(root); + return ESP_OK; +} + +esp_err_t tool_gpio_read_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; + } + + cJSON *pin_obj = cJSON_GetObjectItem(root, "pin"); + if (!cJSON_IsNumber(pin_obj)) { + snprintf(output, output_size, "Error: 'pin' required (integer)"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + int pin = (int)pin_obj->valuedouble; + + if (!gpio_policy_pin_is_allowed(pin)) { + if (gpio_policy_pin_forbidden_hint(pin, output, output_size)) { + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + if (MIMI_GPIO_ALLOWED_CSV[0] != '\0') { + snprintf(output, output_size, "Error: pin %d is not in allowed list", pin); + } else { + snprintf(output, output_size, "Error: pin must be %d-%d", + MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN); + } + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + /* Enable input path, then read level */ + gpio_set_direction(pin, GPIO_MODE_INPUT); + int level = gpio_get_level(pin); + + snprintf(output, output_size, "Pin %d = %s", pin, level ? "HIGH" : "LOW"); + ESP_LOGI(TAG, "gpio_read: pin %d = %s", pin, level ? "HIGH" : "LOW"); + + cJSON_Delete(root); + return ESP_OK; +} + +esp_err_t tool_gpio_read_all_execute(const char *input_json, char *output, size_t output_size) +{ + (void)input_json; + + char *cursor = output; + size_t remaining = output_size; + int written; + int count = 0; + + written = snprintf(cursor, remaining, "GPIO states: "); + if (written < 0 || (size_t)written >= remaining) { + output[0] = '\0'; + return ESP_FAIL; + } + cursor += (size_t)written; + remaining -= (size_t)written; + + if (MIMI_GPIO_ALLOWED_CSV[0] != '\0') { + /* Iterate over explicit allowlist */ + const char *csv_cursor = MIMI_GPIO_ALLOWED_CSV; + while (*csv_cursor != '\0') { + char *endptr = NULL; + long value; + + while (*csv_cursor == ' ' || *csv_cursor == '\t' || *csv_cursor == ',') { + csv_cursor++; + } + if (*csv_cursor == '\0') break; + + value = strtol(csv_cursor, &endptr, 10); + if (endptr == csv_cursor) { + while (*csv_cursor != '\0' && *csv_cursor != ',') csv_cursor++; + continue; + } + if (!gpio_policy_pin_is_allowed((int)value)) { + csv_cursor = endptr; + continue; + } + + gpio_set_direction((int)value, GPIO_MODE_INPUT); + int level = gpio_get_level((int)value); + + written = snprintf(cursor, remaining, "%s%d=%s", + count == 0 ? "" : ", ", + (int)value, level ? "HIGH" : "LOW"); + if (written < 0 || (size_t)written >= remaining) break; + cursor += (size_t)written; + remaining -= (size_t)written; + count++; + csv_cursor = endptr; + } + } else { + /* Iterate over default range */ + for (int pin = MIMI_GPIO_MIN_PIN; pin <= MIMI_GPIO_MAX_PIN; pin++) { + if (!gpio_policy_pin_is_allowed(pin)) continue; + + gpio_set_direction(pin, GPIO_MODE_INPUT); + int level = gpio_get_level(pin); + + written = snprintf(cursor, remaining, "%s%d=%s", + count == 0 ? "" : ", ", + pin, level ? "HIGH" : "LOW"); + if (written < 0 || (size_t)written >= remaining) break; + cursor += (size_t)written; + remaining -= (size_t)written; + count++; + } + } + + if (count == 0) { + snprintf(output, output_size, "Error: no allowed GPIO pins configured"); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "gpio_read_all: %d pins read", count); + return ESP_OK; +} diff --git a/main/tools/tool_gpio.h b/main/tools/tool_gpio.h new file mode 100644 index 0000000..b435b15 --- /dev/null +++ b/main/tools/tool_gpio.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esp_err.h" +#include + +/** + * Initialize GPIO tool — configure allowed pins and directions. + */ +esp_err_t tool_gpio_init(void); + +/** + * Write a GPIO pin HIGH or LOW. + * Input JSON: {"pin": , "state": <0|1>} + */ +esp_err_t tool_gpio_write_execute(const char *input_json, char *output, size_t output_size); + +/** + * Read a single GPIO pin state. + * Input JSON: {"pin": } + */ +esp_err_t tool_gpio_read_execute(const char *input_json, char *output, size_t output_size); + +/** + * Read all allowed GPIO pin states at once. + * Input JSON: {} (no parameters) + */ +esp_err_t tool_gpio_read_all_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 6c82a3e..41128c4 100644 --- a/main/tools/tool_registry.c +++ b/main/tools/tool_registry.c @@ -4,6 +4,7 @@ #include "tools/tool_get_time.h" #include "tools/tool_files.h" #include "tools/tool_cron.h" +#include "tools/tool_gpio.h" #include #include "esp_log.h" @@ -11,7 +12,7 @@ static const char *TAG = "tools"; -#define MAX_TOOLS 12 +#define MAX_TOOLS 16 static mimi_tool_t s_tools[MAX_TOOLS]; static int s_tool_count = 0; @@ -176,6 +177,43 @@ esp_err_t tool_registry_init(void) }; register_tool(&cr); + /* Register GPIO tools */ + tool_gpio_init(); + + mimi_tool_t gw = { + .name = "gpio_write", + .description = "Set a GPIO pin HIGH or LOW. Controls LEDs, relays, and other digital outputs.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{\"pin\":{\"type\":\"integer\",\"description\":\"GPIO pin number\"}," + "\"state\":{\"type\":\"integer\",\"description\":\"1 for HIGH, 0 for LOW\"}}," + "\"required\":[\"pin\",\"state\"]}", + .execute = tool_gpio_write_execute, + }; + register_tool(&gw); + + mimi_tool_t gr = { + .name = "gpio_read", + .description = "Read a GPIO pin state. Returns HIGH or LOW. Use for checking switches, sensors, and digital inputs.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{\"pin\":{\"type\":\"integer\",\"description\":\"GPIO pin number\"}}," + "\"required\":[\"pin\"]}", + .execute = tool_gpio_read_execute, + }; + register_tool(&gr); + + mimi_tool_t ga = { + .name = "gpio_read_all", + .description = "Read all allowed GPIO pin states in a single call. Returns each pin's HIGH/LOW state.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{}," + "\"required\":[]}", + .execute = tool_gpio_read_all_execute, + }; + register_tool(&ga); + build_tools_json(); ESP_LOGI(TAG, "Tool registry initialized"); diff --git a/spiffs_data/skills/gpio-control.md b/spiffs_data/skills/gpio-control.md new file mode 100644 index 0000000..9b29336 --- /dev/null +++ b/spiffs_data/skills/gpio-control.md @@ -0,0 +1,37 @@ +# GPIO Control + +Control and monitor GPIO pins on the ESP32-S3 for digital I/O. + +## When to use +When the user asks to: +- Turn on/off LEDs, relays, or other outputs +- Check switch states, button presses, or sensor readings +- Confirm digital I/O status (switch confirmation) +- Get an overview of all GPIO pin states + +## How to use +1. To **read a switch/sensor**: use gpio_read with the pin number + - Returns HIGH (1) or LOW (0) + - HIGH typically means switch is ON / circuit closed + - LOW typically means switch is OFF / circuit open +2. To **set an output**: use gpio_write with pin and state (1=HIGH, 0=LOW) +3. To **scan all pins**: use gpio_read_all for a full status overview +4. For **switch confirmation**: read the pin, report state, optionally toggle and re-read to verify + +## Pin safety +- Only pins within the allowed range can be accessed +- ESP32 flash pins (6-11) are always blocked +- If a pin is rejected, suggest an alternative within the allowed range + +## Example +User: "Check if the switch on pin 4 is on" +→ gpio_read {"pin": 4} +→ "Pin 4 = HIGH" +→ "The switch on pin 4 is currently ON (HIGH)." + +User: "Turn on the relay on pin 5" +→ gpio_write {"pin": 5, "state": 1} +→ "Pin 5 set to HIGH" +→ gpio_read {"pin": 5} +→ "Pin 5 = HIGH" +→ "Relay on pin 5 is now ON. Confirmed HIGH."