diff --git a/.github/workflows/esp-idf.yaml b/.github/workflows/esp-idf.yaml index f13f299..3d068f3 100644 --- a/.github/workflows/esp-idf.yaml +++ b/.github/workflows/esp-idf.yaml @@ -2,9 +2,9 @@ name: ESP-IDF on: push: - branches: ["master"] + branches: ["*"] pull_request: - branches: ["master"] + branches: ["*"] jobs: build: diff --git a/CMakeLists.txt b/CMakeLists.txt index 70277a8..0eeb749 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,7 +8,7 @@ include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(jaculus) -fatfs_create_spiflash_image("storage" "./data" FLASH_IN_PROJECT) +# fatfs_create_spiflash_image("storage" "./data" FLASH_IN_PROJECT) # Print detailed leak information from quickJS before the assert # idf_build_set_property(COMPILE_OPTIONS "-DDUMP_LEAKS=1" APPEND) diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 036aff2..3794240 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -5,7 +5,7 @@ idf_component_register( "espFeatures/gridui/gridUiFeature.cpp" "espFeatures/gridui/widgets/_common.cpp" INCLUDE_DIRS "" REQUIRES jac-dcore jac-machine jac-link - driver pthread spiffs vfs fatfs + driver pthread spiffs vfs fatfs esp_http_client SmartLeds esp_timer Esp32-RBGridUI ) diff --git a/main/espFeatures/httpClientFeature.h b/main/espFeatures/httpClientFeature.h new file mode 100644 index 0000000..b9f4b84 --- /dev/null +++ b/main/espFeatures/httpClientFeature.h @@ -0,0 +1,307 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "esp_http_client.h" +#include "esp_log.h" +#include "../platform/espWifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + + +template +class HttpClientFeature : public Next { + + class HttpClient { + private: + static const char* TAG; + jac::ContextRef _ctx; + HttpClientFeature* _feature; + + struct HttpResponse { + char* data; + size_t size; + int status_code; + bool success; + + private: + void initializeEmptyData() { + data = (char*)malloc(1); + if (data) { + data[0] = '\0'; + } + } + + void copyDataFrom(const HttpResponse& other) { + if (other.data && other.size > 0) { + data = (char*)malloc(other.size + 1); + if (data) { + memcpy(data, other.data, other.size); + data[other.size] = '\0'; + } + } else { + initializeEmptyData(); + } + } + + public: + HttpResponse() : data(nullptr), size(0), status_code(0), success(false) { + initializeEmptyData(); + } + + HttpResponse(const HttpResponse& other) : data(nullptr), size(other.size), status_code(other.status_code), success(other.success) { + copyDataFrom(other); + } + + HttpResponse(HttpResponse&& other) noexcept : data(other.data), size(other.size), status_code(other.status_code), success(other.success) { + other.data = nullptr; + other.size = 0; + } + + HttpResponse& operator=(const HttpResponse& other) { + if (this != &other) { + free(data); + size = other.size; + status_code = other.status_code; + success = other.success; + copyDataFrom(other); + } + return *this; + } + + HttpResponse& operator=(HttpResponse&& other) noexcept { + if (this != &other) { + free(data); + data = other.data; + size = other.size; + status_code = other.status_code; + success = other.success; + other.data = nullptr; + other.size = 0; + } + return *this; + } + + ~HttpResponse() { + free(data); + } + }; + + struct HttpTaskData { + std::string url; + std::string method; + std::string data; + std::string contentType; + jac::Function resolve; + jac::Function reject; + jac::ContextRef ctx; + HttpClientFeature* feature; + + HttpTaskData(std::string url, std::string method, std::string data, std::string contentType, + jac::Function resolve, jac::Function reject, jac::ContextRef ctx, HttpClientFeature* feature) + : url(std::move(url)), method(std::move(method)), data(std::move(data)), contentType(std::move(contentType)), + resolve(std::move(resolve)), reject(std::move(reject)), ctx(ctx), feature(feature) {} + }; + + // Helper function to create JavaScript Error objects + static jac::Object createErrorObject(jac::ContextRef ctx, const std::string& message) { + jac::Object errorObj = jac::Object::create(ctx); + errorObj.set("name", "Error"); + errorObj.set("message", message); + return errorObj; + } + + static esp_err_t httpEventHandler(esp_http_client_event_t *evt) { + HttpResponse* response = (HttpResponse*)evt->user_data; + + switch(evt->event_id) { + case HTTP_EVENT_ON_DATA: + if (response->data) { + char* new_data = (char*)realloc(response->data, response->size + evt->data_len + 1); + if (new_data) { + response->data = new_data; + memcpy(response->data + response->size, evt->data, evt->data_len); + response->size += evt->data_len; + response->data[response->size] = '\0'; + } + } + break; + case HTTP_EVENT_ON_FINISH: + response->status_code = esp_http_client_get_status_code(evt->client); + response->success = (response->status_code == 200); + break; + case HTTP_EVENT_ERROR: + ESP_LOGE(TAG, "HTTP_EVENT_ERROR"); + break; + default: + break; + } + return ESP_OK; + } + + static void httpRequestTask(void* pvParameters) { + HttpTaskData* taskData = static_cast(pvParameters); + + // Double-check WiFi connectivity inside the task + auto& wifi = EspWifiController::get(); + if (wifi.currentIp().addr == 0) { + taskData->feature->scheduleEvent([taskData]() { + auto errorObj = createErrorObject(taskData->ctx, "WiFi connection lost during request"); + taskData->reject.template call(errorObj); + delete taskData; + }); + vTaskDelete(NULL); + return; + } + + HttpResponse* response = new HttpResponse(); + + esp_http_client_config_t config = {}; + config.url = taskData->url.c_str(); + config.event_handler = httpEventHandler; + config.user_data = response; + config.timeout_ms = 5000; + + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { + taskData->feature->scheduleEvent([taskData, response]() { + auto errorObj = createErrorObject(taskData->ctx, "Failed to initialize HTTP client"); + taskData->reject.template call(errorObj); + delete response; // Clean up response + delete taskData; + }); + vTaskDelete(NULL); + return; + } + + // Configure HTTP method and data + esp_http_client_method_t httpMethod = HTTP_METHOD_GET; + if (taskData->method == "POST") { + httpMethod = HTTP_METHOD_POST; + } else if (taskData->method == "PUT") { + httpMethod = HTTP_METHOD_PUT; + } else if (taskData->method == "DELETE") { + httpMethod = HTTP_METHOD_DELETE; + } + esp_http_client_set_method(client, httpMethod); + + // Set data for POST/PUT methods + if ((taskData->method == "POST" || taskData->method == "PUT") && !taskData->data.empty()) { + esp_http_client_set_header(client, "Content-Type", taskData->contentType.c_str()); + esp_http_client_set_post_field(client, taskData->data.c_str(), taskData->data.length()); + } + + esp_err_t err = esp_http_client_perform(client); + esp_http_client_cleanup(client); + + // Schedule the response callback on the main thread + taskData->feature->scheduleEvent([taskData, response, err]() mutable { + if (err != ESP_OK) { + auto errorObj = createErrorObject(taskData->ctx, "HTTP request failed"); + taskData->reject.template call(errorObj); + } else { + jac::Object result = jac::Object::create(taskData->ctx); + result.set("status", response->status_code); + result.set("body", response->data ? std::string(response->data) : ""); + taskData->resolve.template call(result); + } + delete response; + delete taskData; + }); + + vTaskDelete(NULL); + } + + public: + HttpClient() : _ctx(nullptr), _feature(nullptr) {} + HttpClient(jac::ContextRef ctx, HttpClientFeature* feature) : _ctx(ctx), _feature(feature) {} + + jac::Value request(std::string url, std::string method = "GET", std::string data = "", std::string contentType = "application/json") { + // Check if WiFi is connected - throw exception if no IP + auto& wifi = EspWifiController::get(); + if (wifi.currentIp().addr == 0) { + throw jac::Exception::create(jac::Exception::Type::Error, "No IP address - WiFi not connected"); + } + + auto [promise, resolve, reject] = jac::Promise::create(_ctx); + + // Create task data with all the request information + HttpTaskData* taskData = new HttpTaskData(url, method, data, contentType, resolve, reject, _ctx, _feature); + + // Create a FreeRTOS task to handle the HTTP request asynchronously + BaseType_t result = xTaskCreate( + httpRequestTask, // Task function + "http_request", // Task name + 8192, // Stack size (8KB) + taskData, // Parameters passed to task + 5, // Priority + nullptr // Task handle (we don't need it) + ); + + if (result != pdPASS) { + delete taskData; + throw jac::Exception::create(jac::Exception::Type::Error, "Failed to create HTTP request task"); + } + + return promise; + } + + jac::Value get(std::string url) { + return request(url, "GET"); + } + + jac::Value post(std::string url, std::string data = "", std::string contentType = "application/json") { + return request(url, "POST", data, contentType); + } + + jac::Value put(std::string url, std::string data = "", std::string contentType = "application/json") { + return request(url, "PUT", data, contentType); + } + + jac::Value del(std::string url) { + return request(url, "DELETE"); + } + }; + +public: + HttpClient http; + +private: + // Helper function to create variadic HTTP method functions + auto createHttpMethodFunction(const std::string& method) { + return [this, method](std::vector args) { + if (args.size() < 1 || args.size() > 3) { + throw std::runtime_error("Invalid number of arguments for " + method + " method"); + } + + std::string url = args[0].to(); + std::string data = (args.size() > 1) ? args[1].to() : ""; + std::string contentType = (args.size() > 2) ? args[2].to() : "application/json"; + + return http.request(url, method, data, contentType); + }; + } + +public: + void initialize() { + Next::initialize(); + + http = HttpClient(this->context(), this); + + jac::FunctionFactory ff(this->context()); + jac::Module& httpClientModule = this->newModule("httpClient"); + + httpClientModule.addExport("get", ff.newFunction(noal::function(&HttpClient::get, &http))); + httpClientModule.addExport("post", ff.newFunctionVariadic(createHttpMethodFunction("POST"))); + httpClientModule.addExport("put", ff.newFunctionVariadic(createHttpMethodFunction("PUT"))); + httpClientModule.addExport("del", ff.newFunction(noal::function(&HttpClient::del, &http))); + } +}; + +template +const char* HttpClientFeature::HttpClient::TAG = "HTTP_CLIENT"; diff --git a/main/espFeatures/wifiFeature.h b/main/espFeatures/wifiFeature.h index 9754382..94d4c4b 100644 --- a/main/espFeatures/wifiFeature.h +++ b/main/espFeatures/wifiFeature.h @@ -24,5 +24,19 @@ class WifiFeature : public Next { return jac::Value::from(this->context(), wifi.currentIpStr()); } }))); + + module.addExport("waitForIp", ff.newFunctionVariadic([this](std::vector args) { + if (args.size() > 1) { + throw std::runtime_error("Invalid number of arguments for waitForIp method"); + } + + uint32_t timeoutMs = 0; // Default: wait forever + if (args.size() == 1) { + timeoutMs = args[0].to(); + } + + auto& wifi = EspWifiController::get(); + return jac::Value::from(this->context(), wifi.waitForIp(timeoutMs)); + })); } }; diff --git a/main/main.cpp b/main/main.cpp index a73b6d6..cd9dcc6 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -26,6 +26,7 @@ #include "espFeatures/pulseCounterFeature.h" #include "espFeatures/timestampFeature.h" #include "espFeatures/wifiFeature.h" +#include "espFeatures/httpClientFeature.h" #include "espFeatures/gridui/gridUiFeature.h" #include "espFeatures/motorFeature.h" @@ -84,6 +85,7 @@ using Machine = jac::ComposeMachine< SimpleRadioFeature, MotorFeature, WifiFeature, + HttpClientFeature, GridUiFeature, jac::KeyValueFeature, jac::EventLoopTerminal diff --git a/main/platform/espWifi.cpp b/main/platform/espWifi.cpp index 2146e6c..5b94c39 100644 --- a/main/platform/espWifi.cpp +++ b/main/platform/espWifi.cpp @@ -2,6 +2,8 @@ #include "esp_wifi.h" #include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include "espWifi.h" EspWifiController::EspWifiController() : @@ -412,3 +414,24 @@ void EspWifiController::eventHandlerIp(void* selfVoid, esp_event_base_t event_ba esp_ip4addr_ntoa(&event->ip_info.ip, buf, sizeof(buf)); jac::Logger::debug("SYSTEM_EVENT_STA_GOT_IP: " + std::string(buf)); } + +bool EspWifiController::waitForIp(uint32_t timeoutMs) { + const uint32_t pollIntervalMs = 100; + uint32_t elapsedMs = 0; + + while (true) { + // Check if we have an IP address + if (_currentIp.addr != 0) { + return true; + } + + // Check timeout + if (timeoutMs > 0 && elapsedMs >= timeoutMs) { + return false; + } + + // Wait for poll interval + vTaskDelay(pdMS_TO_TICKS(pollIntervalMs)); + elapsedMs += pollIntervalMs; + } +} diff --git a/main/platform/espWifi.h b/main/platform/espWifi.h index 949be67..8ba9904 100644 --- a/main/platform/espWifi.h +++ b/main/platform/espWifi.h @@ -88,5 +88,12 @@ class EspWifiController { return buf; } + /** + * @brief Wait for WiFi to get an IP address + * @param timeoutMs Maximum time to wait in milliseconds (0 = wait forever) + * @return true if IP was obtained, false if timeout occurred + */ + bool waitForIp(uint32_t timeoutMs = 0); + Mode mode() const { return _mode; } }; diff --git a/main/resources/CMakeLists.txt b/main/resources/CMakeLists.txt index 437a85f..99ef5a0 100644 --- a/main/resources/CMakeLists.txt +++ b/main/resources/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.0) +cmake_minimum_required(VERSION 3.10) set(ts_examples_dir ${CMAKE_CURRENT_SOURCE_DIR}/../../ts-examples) set(ts_examples_tgz ts_examples.tar.gz) diff --git a/ts-examples/@types/httpClient.d.ts b/ts-examples/@types/httpClient.d.ts new file mode 100644 index 0000000..6060251 --- /dev/null +++ b/ts-examples/@types/httpClient.d.ts @@ -0,0 +1,46 @@ +declare module "httpClient" { + /** + * HTTP Response object returned by all HTTP methods + */ + interface HttpResponse { + status: number; // HTTP status code (200, 404, etc.) + body: string; // Response body as string + } + + /** + * Performs an HTTP GET request. + * @param url The URL to request. + * @returns A promise that resolves with the HTTP response or rejects with an error. + * @throws Exception if no IP address (WiFi not connected) or task creation fails. + */ + function get(url: string): Promise; + + /** + * Performs an HTTP POST request. + * @param url The URL to request. + * @param data Optional request body data (default: ""). + * @param contentType Optional content type header (default: "application/json"). + * @returns A promise that resolves with the HTTP response or rejects with an error. + * @throws Exception if no IP address (WiFi not connected) or task creation fails. + */ + function post(url: string, data?: string, contentType?: string): Promise; + + /** + * Performs an HTTP PUT request. + * @param url The URL to request. + * @param data Optional request body data (default: ""). + * @param contentType Optional content type header (default: "application/json"). + * @returns A promise that resolves with the HTTP response or rejects with an error. + * @throws Exception if no IP address (WiFi not connected) or task creation fails. + */ + function put(url: string, data?: string, contentType?: string): Promise; + + /** + * Performs an HTTP DELETE request. + * @param url The URL to request. + * @returns A promise that resolves with the HTTP response or rejects with an error. + * @throws Exception if no IP address (WiFi not connected) or task creation fails. + * @note 'delete' is a TypeScript keyword, so the function is named 'del'. + */ + function del(url: string): Promise; +} diff --git a/ts-examples/@types/wifi.d.ts b/ts-examples/@types/wifi.d.ts index 7e53219..7b6c196 100644 --- a/ts-examples/@types/wifi.d.ts +++ b/ts-examples/@types/wifi.d.ts @@ -3,4 +3,11 @@ declare module "wifi" { * Return current IPv4 of the device, or null if WiFi is disabled or not connected. */ function currentIp(): string | null; + + /** + * Waits for the device to obtain an IP address. + * @param timeoutMs Optional timeout in milliseconds. If not provided, waits indefinitely. + * @returns A promise that resolves to true if an IP address is obtained, or false if the timeout is reached. + */ + function waitForIp(timeoutMs?: number): boolean; } diff --git a/ts-examples/src/httpClientDemo.ts b/ts-examples/src/httpClientDemo.ts new file mode 100644 index 0000000..75c110a --- /dev/null +++ b/ts-examples/src/httpClientDemo.ts @@ -0,0 +1,187 @@ +import * as httpClient from 'httpClient'; +import { stdout } from 'stdio'; +import { waitForIp, currentIp } from 'wifi'; + +/** + * Comprehensive demonstration of the httpClient module. + * + * This example showcases all available HTTP methods: + * - GET: Retrieve data from a server + * - POST: Send data to create a resource + * - PUT: Send data to update a resource + * - DELETE: Remove a resource + * + * NOTE: Ensure your device is connected to WiFi with internet access + * before running these examples. + */ + +async function runHttpExamples() { + stdout.write("=== HTTP Client Examples ===\n"); + + // Wait for WiFi connection + stdout.write("Waiting for IP address...\n"); + waitForIp(); + stdout.write(`Connected! IP: ${currentIp()}\n\n`); + + // Example 1: Basic GET request + try { + stdout.write("1. GET Request Example\n"); + stdout.write(" URL: http://httpbin.org/get\n"); + + const getResponse = await httpClient.get("http://httpbin.org/get"); + + stdout.write(` Status: ${getResponse.status}\n`); + stdout.write(` Body length: ${getResponse.body.length} characters\n`); + + if (getResponse.status === 200) { + // Parse a small part of the JSON to show it works + const bodyJson = JSON.parse(getResponse.body); + stdout.write(` Your IP: ${bodyJson.origin}\n`); + } + + stdout.write(" ✓ GET request successful!\n\n"); + } catch (error) { + stdout.write(` ✗ GET Error: ${error.message}\n\n`); + } + + await sleep(1000); + + // Example 2: POST request with JSON data + try { + stdout.write("2. POST Request Example\n"); + stdout.write(" URL: http://httpbin.org/post\n"); + + const postData = JSON.stringify({ + message: "Hello from ESP32!", + timestamp: Date.now(), + device: "ESP32-S3" + }); + + const postResponse = await httpClient.post( + "http://httpbin.org/post", + postData, + "application/json" + ); + + stdout.write(` Status: ${postResponse.status}\n`); + stdout.write(` Body length: ${postResponse.body.length} characters\n`); + + if (postResponse.status === 200) { + const bodyJson = JSON.parse(postResponse.body); + stdout.write(` Data echoed back: ${bodyJson.data}\n`); + } + + stdout.write(" ✓ POST request successful!\n\n"); + } catch (error) { + stdout.write(` ✗ POST Error: ${error.message}\n\n`); + } + + await sleep(1000); + + // Example 3: PUT request + try { + stdout.write("3. PUT Request Example\n"); + stdout.write(" URL: http://httpbin.org/put\n"); + + const putData = JSON.stringify({ + id: 123, + name: "Updated Resource", + value: "new value" + }); + + const putResponse = await httpClient.put( + "http://httpbin.org/put", + putData, + "application/json" + ); + + stdout.write(` Status: ${putResponse.status}\n`); + stdout.write(` Body length: ${putResponse.body.length} characters\n`); + stdout.write(" ✓ PUT request successful!\n\n"); + } catch (error) { + stdout.write(` ✗ PUT Error: ${error.message}\n\n`); + } + + await sleep(1000); + + // Example 4: DELETE request + try { + stdout.write("4. DELETE Request Example\n"); + stdout.write(" URL: http://httpbin.org/delete\n"); + + const deleteResponse = await httpClient.del("http://httpbin.org/delete"); + + stdout.write(` Status: ${deleteResponse.status}\n`); + stdout.write(` Body length: ${deleteResponse.body.length} characters\n`); + stdout.write(" ✓ DELETE request successful!\n\n"); + } catch (error) { + stdout.write(` ✗ DELETE Error: ${error.message}\n\n`); + } + + await sleep(1000); + + // Example 5: Different content types + try { + stdout.write("5. POST with Plain Text\n"); + stdout.write(" URL: http://httpbin.org/post\n"); + + const textResponse = await httpClient.post( + "http://httpbin.org/post", + "This is plain text data from ESP32", + "text/plain" + ); + + stdout.write(` Status: ${textResponse.status}\n`); + stdout.write(" ✓ Plain text POST successful!\n\n"); + } catch (error) { + stdout.write(` ✗ Text POST Error: ${error.message}\n\n`); + } + + await sleep(1000); + + // Example 6: Error handling - invalid URL + try { + stdout.write("6. Error Handling Example\n"); + stdout.write(" URL: http://invalid-domain-that-does-not-exist.com\n"); + + const errorResponse = await httpClient.get("http://invalid-domain-that-does-not-exist.com"); + stdout.write(` Unexpected success: ${errorResponse.status}\n`); + } catch (error) { + stdout.write(` ✓ Expected error caught: ${error.message}\n\n`); + } + + stdout.write("=== All HTTP Client Examples Completed ===\n"); + stdout.write("The HTTP client is working correctly!\n"); + + // Optional: Run a continuous test loop (uncomment to enable) + await runContinuousTest(); +} + +/** + * Optional continuous test - uncomment the call above to enable + */ +async function runContinuousTest() { + stdout.write("\n--- Starting continuous test (every 5 seconds) ---\n"); + + let requestCount = 0; + while (true) { + try { + requestCount++; + stdout.write(`Request #${requestCount}: `); + + const response = await httpClient.get("http://httpbin.org/get"); + stdout.write(`Status ${response.status}, ${response.body.length} chars\n`); + + } catch (error) { + stdout.write(`Error: ${error.message}\n`); + } + + await sleep(500); // 5 second intervals + } +} + +// Start the examples +runHttpExamples().catch(error => { + stdout.write(`Fatal error: ${error.message}\n`); + exit(1); +}); diff --git a/ts-examples/src/httpClientDemoAsync.ts b/ts-examples/src/httpClientDemoAsync.ts new file mode 100644 index 0000000..5c442f7 --- /dev/null +++ b/ts-examples/src/httpClientDemoAsync.ts @@ -0,0 +1,35 @@ +import * as httpClient from 'httpClient'; +import { stdout } from 'stdio'; // For logging +import { waitForIp, currentIp } from 'wifi'; // For logging + + +async function runHttpExamples() { + console.error("Waiting for IP address before exiting..."); + waitForIp(); + console.error("Connected IP:" + currentIp()); + + stdout.write("Running httpClient examples...\n"); + + while (true) { + const response_hdl = httpClient.get("http://httpbin.org/get"); + + for (let i = 0; i < 3; i++) { // Reduced from 10 to 3 + stdout.write(`Waiting for response... (${i + 1}/3)\n`); + await sleep(500); // Increased from 100ms to 500ms + } + + const response = await response_hdl; + + console.log("Status:" + response.status); + console.log("Body length:" + response.body.length + " chars"); // Just length instead of full body + // console.log("Body:" + response.body); // Comment out large output + + await sleep(3000); // Increased from 1000ms to 3000ms + } +} + +// Run the examples +runHttpExamples().catch(e => { + stdout.write(`Unhandled global error in examples: ${e.message}\n`); + exit(1); +});