From 4fae263e75723ee7a1a3bc252ccef76003c8f80d Mon Sep 17 00:00:00 2001 From: mcuw <112967559+mcuw@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:58:58 +0200 Subject: [PATCH 1/5] feature: add CoAP Light Control --- ot_br/components/ot_br_web_api/CMakeLists.txt | 2 +- .../include/ot_br_web_api_coap_client.h | 20 +++ .../include/ot_br_web_api_command.h | 3 + .../ot_br_web_api/ot_br_web_api_coap_client.c | 167 ++++++++++++++++++ .../ot_br_web_api/ot_br_web_api_command.c | 58 ++++++ .../ot_br_web_api/ot_br_web_api_handlers.c | 6 +- ot_br/main/esp_ot_br.c | 6 + web/src/components/CoapLightControl.tsx | 92 ++++++++++ web/src/components/Dashboard.tsx | 2 + 9 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 ot_br/components/ot_br_web_api/include/ot_br_web_api_coap_client.h create mode 100644 ot_br/components/ot_br_web_api/ot_br_web_api_coap_client.c create mode 100644 web/src/components/CoapLightControl.tsx diff --git a/ot_br/components/ot_br_web_api/CMakeLists.txt b/ot_br/components/ot_br_web_api/CMakeLists.txt index 288e3c2..e1e1d24 100644 --- a/ot_br/components/ot_br_web_api/CMakeLists.txt +++ b/ot_br/components/ot_br_web_api/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "ot_br_web_api.c" "ot_br_web_api_auth.c" "ot_br_web_api_handlers.c" "ot_br_web_api_ota.c" "ot_br_web_api_static.c" "ot_br_web_api_setup_mode.c" "ot_br_web_api_command.c" + SRCS "ot_br_web_api.c" "ot_br_web_api_auth.c" "ot_br_web_api_handlers.c" "ot_br_web_api_ota.c" "ot_br_web_api_static.c" "ot_br_web_api_setup_mode.c" "ot_br_web_api_command.c" "ot_br_web_api_coap_client.c" INCLUDE_DIRS "include" REQUIRES esp_http_server openthread nvs_flash esp_hw_support esp_app_format mbedtls espressif__cjson app_update esp_https_ota esp_http_client joltwallet__littlefs esp_driver_gpio esp_timer ) \ No newline at end of file diff --git a/ot_br/components/ot_br_web_api/include/ot_br_web_api_coap_client.h b/ot_br/components/ot_br_web_api/include/ot_br_web_api_coap_client.h new file mode 100644 index 0000000..dae9484 --- /dev/null +++ b/ot_br/components/ot_br_web_api/include/ot_br_web_api_coap_client.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +void ot_br_coap_client_init(void); + +esp_err_t ot_br_coap_light_set(const char *address_str, bool on, uint8_t r, uint8_t g, uint8_t b); + +// static void diagnostic_callback(otError err, otMessage *message, +// const otMessageInfo *info, void *ctx); + +esp_err_t ot_br_resolve_omr_address(const char *rloc_address_str, char *out, size_t out_len); + +#ifdef __cplusplus +} +#endif \ No newline at end of file diff --git a/ot_br/components/ot_br_web_api/include/ot_br_web_api_command.h b/ot_br/components/ot_br_web_api/include/ot_br_web_api_command.h index 8dab719..6082c5f 100644 --- a/ot_br/components/ot_br_web_api/include/ot_br_web_api_command.h +++ b/ot_br/components/ot_br_web_api/include/ot_br_web_api_command.h @@ -14,6 +14,9 @@ void ot_br_command_build_rloc_address(uint16_t rloc16, char *out, size_t out_len esp_err_t ot_br_command_send_handler(httpd_req_t *req); +// CoAP Light Control +esp_err_t ot_br_coap_light_handler(httpd_req_t *req); + #ifdef __cplusplus } #endif \ No newline at end of file diff --git a/ot_br/components/ot_br_web_api/ot_br_web_api_coap_client.c b/ot_br/components/ot_br_web_api/ot_br_web_api_coap_client.c new file mode 100644 index 0000000..eb03150 --- /dev/null +++ b/ot_br/components/ot_br_web_api/ot_br_web_api_coap_client.c @@ -0,0 +1,167 @@ +#include +#include "openthread/coap.h" +#include "openthread/ip6.h" +#include "esp_openthread.h" +#include "esp_openthread_lock.h" +#include "esp_log.h" +#include "cJSON.h" +#include "freertos/FreeRTOS.h" +#include "openthread/netdiag.h" +#include "freertos/semphr.h" + +static const char *TAG = "ot_br_coap_client"; +static SemaphoreHandle_t s_response_sem = NULL; +static otCoapCode s_last_response_code = 0; + +// for Diagnostic-Get-request +static SemaphoreHandle_t s_diag_sem = NULL; +static char s_resolved_omr[48] = {0}; + +static void coap_response_handler(void *ctx, otMessage *message, + const otMessageInfo *info, otError result) +{ + if (result == OT_ERROR_NONE) { + s_last_response_code = otCoapMessageGetCode(message); + } else { + s_last_response_code = 0; + ESP_LOGW(TAG, "CoAP-Request fehlgeschlagen: %d", result); + } + xSemaphoreGive(s_response_sem); +} + +void ot_br_coap_client_init(void) +{ + s_response_sem = xSemaphoreCreateBinary(); + + otInstance *instance = esp_openthread_get_instance(); + esp_openthread_lock_acquire(portMAX_DELAY); + otError err = otCoapStart(instance, OT_DEFAULT_COAP_PORT); + esp_openthread_lock_release(); + + if (err != OT_ERROR_NONE && err != OT_ERROR_ALREADY) { + ESP_LOGE("ot_br_coap_client", "otCoapStart fehlgeschlagen: %d", err); + } +} + +esp_err_t ot_br_coap_light_set(const char *address_str, bool on, uint8_t r, uint8_t g, uint8_t b) +{ + otInstance *instance = esp_openthread_get_instance(); + + cJSON *j = cJSON_CreateObject(); + cJSON_AddBoolToObject(j, "on", on); + cJSON_AddNumberToObject(j, "r", r); + cJSON_AddNumberToObject(j, "g", g); + cJSON_AddNumberToObject(j, "b", b); + char *payload = cJSON_PrintUnformatted(j); + cJSON_Delete(j); + + esp_openthread_lock_acquire(portMAX_DELAY); + + otMessage *message = otCoapNewMessage(instance, NULL); + if (!message) { + esp_openthread_lock_release(); + free(payload); + return ESP_ERR_NO_MEM; + } + + otCoapMessageInit(message, OT_COAP_TYPE_CONFIRMABLE, OT_COAP_CODE_PUT); + otCoapMessageGenerateToken(message, OT_COAP_DEFAULT_TOKEN_LENGTH); + otCoapMessageAppendUriPathOptions(message, "light"); + otCoapMessageAppendContentFormatOption(message, OT_COAP_OPTION_CONTENT_FORMAT_JSON); + otCoapMessageSetPayloadMarker(message); + otMessageAppend(message, payload, strlen(payload)); + free(payload); + + otMessageInfo msgInfo; + memset(&msgInfo, 0, sizeof(msgInfo)); + otError addr_err = otIp6AddressFromString(address_str, &msgInfo.mPeerAddr); + if (addr_err != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Ungueltige Zieladresse: '%s'", address_str); + otMessageFree(message); + esp_openthread_lock_release(); + return ESP_ERR_INVALID_ARG; + } + ESP_LOGI(TAG, "Sende CoAP-Request an: %s", address_str); + msgInfo.mPeerPort = OT_DEFAULT_COAP_PORT; // 5683 + + otError err = otCoapSendRequest(instance, message, &msgInfo, + coap_response_handler, NULL); + esp_openthread_lock_release(); + + if (err != OT_ERROR_NONE) { + ESP_LOGW(TAG, "CoAP-Request senden fehlgeschlagen: %d", err); + return ESP_FAIL; + } + + // Auf Antwort warten (max. 3 Sekunden, CoAP kuemmert sich intern um Retransmission) + if (xSemaphoreTake(s_response_sem, pdMS_TO_TICKS(3000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + return (s_last_response_code == OT_COAP_CODE_CHANGED) ? ESP_OK : ESP_FAIL; +} + +static void diagnostic_callback(otError err, otMessage *message, + const otMessageInfo *info, void *ctx) +{ + s_resolved_omr[0] = '\0'; + + if (err == OT_ERROR_NONE && message) { + otNetworkDiagTlv tlv; + otNetworkDiagIterator iterator = OT_NETWORK_DIAGNOSTIC_ITERATOR_INIT; + + while (otThreadGetNextDiagnosticTlv(message, &iterator, &tlv) == OT_ERROR_NONE) { + if (tlv.mType == OT_NETWORK_DIAGNOSTIC_TLV_IP6_ADDR_LIST) { + for (uint8_t i = 0; i < tlv.mData.mIp6AddrList.mCount; i++) { + otIp6Address *addr = &tlv.mData.mIp6AddrList.mList[i]; + // OMR-Praefix des eigenen BR holen und Adresse damit vergleichen + otInstance *instance = esp_openthread_get_instance(); + otIp6Prefix omr; + otError omr_err = otBorderRoutingGetOmrPrefix(instance, &omr); + if (omr_err == OT_ERROR_NONE && + memcmp(addr->mFields.m8, omr.mPrefix.mFields.m8, omr.mLength / 8) == 0) { + otIp6AddressToString(addr, s_resolved_omr, sizeof(s_resolved_omr)); + break; + } + } + } + } + } + + xSemaphoreGive(s_diag_sem); +} + +esp_err_t ot_br_resolve_omr_address(const char *rloc_address_str, char *out, size_t out_len) +{ + if (!s_diag_sem) { + s_diag_sem = xSemaphoreCreateBinary(); + } + s_resolved_omr[0] = '\0'; + + otInstance *instance = esp_openthread_get_instance(); + otIp6Address dest; + + esp_openthread_lock_acquire(portMAX_DELAY); + otIp6AddressFromString(rloc_address_str, &dest); + + uint8_t tlv_types[] = { OT_NETWORK_DIAGNOSTIC_TLV_IP6_ADDR_LIST }; + otError err = otThreadSendDiagnosticGet(instance, &dest, tlv_types, 1, + diagnostic_callback, NULL); + esp_openthread_lock_release(); + + if (err != OT_ERROR_NONE) { + return ESP_FAIL; + } + + if (xSemaphoreTake(s_diag_sem, pdMS_TO_TICKS(5000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + if (s_resolved_omr[0] == '\0') { + return ESP_ERR_NOT_FOUND; + } + + strncpy(out, s_resolved_omr, out_len - 1); + out[out_len - 1] = '\0'; + return ESP_OK; +} \ No newline at end of file diff --git a/ot_br/components/ot_br_web_api/ot_br_web_api_command.c b/ot_br/components/ot_br_web_api/ot_br_web_api_command.c index 2cd25cc..19ceead 100644 --- a/ot_br/components/ot_br_web_api/ot_br_web_api_command.c +++ b/ot_br/components/ot_br_web_api/ot_br_web_api_command.c @@ -12,6 +12,8 @@ #include "ot_br_web_api_internal.h" +#include "ot_br_web_api_coap_client.h" + static const char *TAG = "ot_br_command"; #define COMMAND_PORT 12345 @@ -131,6 +133,62 @@ esp_err_t ot_br_command_send_handler(httpd_req_t *req) return ESP_OK; } + cJSON *j = cJSON_CreateObject(); + cJSON_AddBoolToObject(j, "success", true); + send_json(req, j); + return ESP_OK; +} + +esp_err_t ot_br_coap_light_handler(httpd_req_t *req) +{ + if (!ot_br_web_api_check_auth(req)) return ESP_OK; + + char *body = read_body(req); + if (!body) { + send_json_error(req, "400 Bad Request", "missing body"); + return ESP_OK; + } + cJSON *json = cJSON_Parse(body); + free(body); + if (!json) { + send_json_error(req, "400 Bad Request", "invalid JSON"); + return ESP_OK; + } + + cJSON *addr_item = cJSON_GetObjectItem(json, "address"); + cJSON *on_item = cJSON_GetObjectItem(json, "on"); + cJSON *r_item = cJSON_GetObjectItem(json, "r"); + cJSON *g_item = cJSON_GetObjectItem(json, "g"); + cJSON *b_item = cJSON_GetObjectItem(json, "b"); + + if (!cJSON_IsString(addr_item)) { + cJSON_Delete(json); + send_json_error(req, "400 Bad Request", "address required"); + return ESP_OK; + } + + bool on = cJSON_IsBool(on_item) ? cJSON_IsTrue(on_item) : true; + uint8_t r = cJSON_IsNumber(r_item) ? (uint8_t)r_item->valueint : 255; + uint8_t g = cJSON_IsNumber(g_item) ? (uint8_t)g_item->valueint : 255; + uint8_t b = cJSON_IsNumber(b_item) ? (uint8_t)b_item->valueint : 255; + char address[48]; + strncpy(address, addr_item->valuestring, sizeof(address) - 1); + address[sizeof(address) - 1] = '\0'; + + cJSON_Delete(json); + + char resolved_address[48]; + esp_err_t resolve_err = ot_br_resolve_omr_address(address, resolved_address, sizeof(resolved_address)); + + const char *target = (resolve_err == ESP_OK) ? resolved_address : address; // Fallback auf RLOC + esp_err_t err = ot_br_coap_light_set(target, on, r, g, b); + + if (err != ESP_OK) { + const char *msg = (err == ESP_ERR_TIMEOUT) ? "Device does not respond (Timeout)" : "CoAP-Request failed"; + send_json_error(req, "504 Gateway Timeout", msg); + return ESP_OK; + } + cJSON *j = cJSON_CreateObject(); cJSON_AddBoolToObject(j, "success", true); send_json(req, j); diff --git a/ot_br/components/ot_br_web_api/ot_br_web_api_handlers.c b/ot_br/components/ot_br_web_api/ot_br_web_api_handlers.c index 88bffb2..3c91d1c 100644 --- a/ot_br/components/ot_br_web_api/ot_br_web_api_handlers.c +++ b/ot_br/components/ot_br_web_api/ot_br_web_api_handlers.c @@ -25,9 +25,11 @@ // Command for automatization #include "ot_br_web_api_command.h" - #include "ot_br_web_api_internal.h" +// CoAP Light Control +#include "ot_br_web_api_coap_client.h" + // static const char *TAG = "ot_br_web_handlers"; extern bool ot_br_web_api_check_auth(httpd_req_t *req); @@ -561,6 +563,8 @@ void ot_br_web_api_register_handlers(httpd_handle_t server) { "/api/setup/complete", HTTP_POST, setup_complete_handler, NULL }, // Command for automatization { "/api/thread/send-command", HTTP_POST, ot_br_command_send_handler, NULL }, + // CoAP Light Control + { "/api/thread/coap-light", HTTP_POST, ot_br_coap_light_handler, NULL }, }; for (size_t i = 0; i < sizeof(uris) / sizeof(uris[0]); i++) { diff --git a/ot_br/main/esp_ot_br.c b/ot_br/main/esp_ot_br.c index fc55853..c9987a9 100644 --- a/ot_br/main/esp_ot_br.c +++ b/ot_br/main/esp_ot_br.c @@ -50,6 +50,9 @@ // Setup Token #include "ot_br_web_api_setup_mode.h" +// CoAP Light Control +#include "ot_br_web_api_coap_client.h" + #if CONFIG_OPENTHREAD_STATE_INDICATOR_ENABLE #include "ot_led_strip.h" #endif @@ -137,6 +140,9 @@ void app_main() }; ESP_ERROR_CHECK(esp_openthread_start(&config)); + + ot_br_coap_client_init(); + #if CONFIG_OPENTHREAD_CLI_ESP_EXTENSION esp_cli_custom_command_init(); #endif diff --git a/web/src/components/CoapLightControl.tsx b/web/src/components/CoapLightControl.tsx new file mode 100644 index 0000000..222a2cb --- /dev/null +++ b/web/src/components/CoapLightControl.tsx @@ -0,0 +1,92 @@ +import { $, component$, useSignal, useVisibleTask$ } from '@qwik.dev/core'; +import { type Neighbor, apiGet, apiPost } from '../lib/api'; + +interface NeighborWithAddr extends Neighbor { + rloc_address: string; +} + +export default component$(() => { + const neighbors = useSignal([]); + const selectedExtMac = useSignal(''); + const color = useSignal('#ffffff'); + const message = useSignal(''); + + useVisibleTask$(async () => { + neighbors.value = await apiGet('/thread/neighbors'); + }); + + const setLight = $(async (on: boolean) => { + if (!selectedExtMac.value) { + message.value = 'Please select a device'; + return; + } + + // Frische Adresse holen statt der evtl. veralteten aus dem Dropdown + message.value = 'Searching for current address...'; + const freshNeighbors = + await apiGet('/thread/neighbors'); + const target = freshNeighbors.find( + (n) => n.ext_mac === selectedExtMac.value, + ); + if (!target) { + message.value = 'Device not found in the network'; + return; + } + const token = localStorage.getItem('ot_br_setup_token') ?? ''; + const hex = color.value.replace('#', ''); + const r = Number.parseInt(hex.substring(0, 2), 16); + const g = Number.parseInt(hex.substring(2, 4), 16); + const b = Number.parseInt(hex.substring(4, 6), 16); + + message.value = 'Sending...'; + try { + await apiPost('/thread/coap-light', token, { + address: target.rloc_address, + on, + r, + g, + b, + }); + message.value = 'OK'; + } catch (e) { + message.value = e instanceof Error ? e.message : 'Error'; + } + }); + + return ( +
+

CoAP - Light Control

+ + +
+ + +
+ {message.value &&

{message.value}

} +
+ ); +}); diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index e3de987..8dd2770 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -1,5 +1,6 @@ import { $, component$, useSignal, useVisibleTask$ } from '@qwik.dev/core'; import { apiGet } from '../lib/api'; +import CoapLightControl from './CoapLightControl'; import CommissionerPanel from './CommissionerPanel'; import DeviceControl from './DeviceControl'; import DeviceInfo from './DeviceInfo'; @@ -59,6 +60,7 @@ export default component$(() => { + ); } From eb102f0280f6ad8b79236df2f8e9d604e66509ab Mon Sep 17 00:00:00 2001 From: mcuw <112967559+mcuw@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:19:08 +0200 Subject: [PATCH 2/5] feature: auto poll seachring for new devices --- web/src/components/CoapLightControl.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/web/src/components/CoapLightControl.tsx b/web/src/components/CoapLightControl.tsx index 222a2cb..8dbc600 100644 --- a/web/src/components/CoapLightControl.tsx +++ b/web/src/components/CoapLightControl.tsx @@ -13,6 +13,17 @@ export default component$(() => { useVisibleTask$(async () => { neighbors.value = await apiGet('/thread/neighbors'); + + const fetchNeighbors = async () => { + try { + neighbors.value = await apiGet('/thread/neighbors'); + } catch (e) { + console.error('Neighbor-Fetch fehlgeschlagen:', e); + } + }; + fetchNeighbors(); + const interval = setInterval(fetchNeighbors, 5000); + return () => clearInterval(interval); }); const setLight = $(async (on: boolean) => { From 7011798190729a5075216b0e2fcb16f8377784cb Mon Sep 17 00:00:00 2001 From: mcuw <112967559+mcuw@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:40:50 +0200 Subject: [PATCH 3/5] feature: replace DeviceControl with a generic CoAP tester --- .../ot_br_web_api/include/ot_br_web_api.h | 3 + .../include/ot_br_web_api_coap_client.h | 14 +- .../ot_br_web_api/ot_br_web_api_coap_client.c | 92 +++++++++++++ .../ot_br_web_api/ot_br_web_api_command.c | 84 +++++++++++ .../ot_br_web_api/ot_br_web_api_handlers.c | 2 + web/src/components/CoapTester.tsx | 130 ++++++++++++++++++ web/src/components/Dashboard.tsx | 4 +- web/src/components/DeviceControl.tsx | 87 ------------ web/src/layouts/Layout.astro | 29 ++++ 9 files changed, 354 insertions(+), 91 deletions(-) create mode 100644 web/src/components/CoapTester.tsx delete mode 100644 web/src/components/DeviceControl.tsx diff --git a/ot_br/components/ot_br_web_api/include/ot_br_web_api.h b/ot_br/components/ot_br_web_api/include/ot_br_web_api.h index a539daa..636c08a 100644 --- a/ot_br/components/ot_br_web_api/include/ot_br_web_api.h +++ b/ot_br/components/ot_br_web_api/include/ot_br_web_api.h @@ -26,6 +26,9 @@ esp_err_t ot_br_web_api_mount_littlefs(void); // web: register static handler to deliver the UI (after all other handlers) void ot_br_web_api_register_static_handler(httpd_handle_t server); +// CoAP Testing handler (POST /api/thread/coap-request) +esp_err_t ot_br_coap_generic_handler(httpd_req_t *req); + #ifdef __cplusplus } #endif \ No newline at end of file diff --git a/ot_br/components/ot_br_web_api/include/ot_br_web_api_coap_client.h b/ot_br/components/ot_br_web_api/include/ot_br_web_api_coap_client.h index dae9484..09750d4 100644 --- a/ot_br/components/ot_br_web_api/include/ot_br_web_api_coap_client.h +++ b/ot_br/components/ot_br_web_api/include/ot_br_web_api_coap_client.h @@ -1,6 +1,7 @@ #pragma once #include "esp_err.h" +#include "openthread/coap.h" #ifdef __cplusplus extern "C" { @@ -10,11 +11,20 @@ void ot_br_coap_client_init(void); esp_err_t ot_br_coap_light_set(const char *address_str, bool on, uint8_t r, uint8_t g, uint8_t b); -// static void diagnostic_callback(otError err, otMessage *message, -// const otMessageInfo *info, void *ctx); +esp_err_t ot_br_resolve_omr_address(const char *rloc_address_str, char *out, size_t out_len); +// CoAP-Test-Client (for CoAP Testing) +typedef struct { + otCoapCode code; + char payload[256]; + bool has_payload; +} ot_br_coap_response_t; esp_err_t ot_br_resolve_omr_address(const char *rloc_address_str, char *out, size_t out_len); +esp_err_t ot_br_coap_generic_request(const char *address_str, otCoapCode method, + const char *uri_path, const char *payload, + ot_br_coap_response_t *out_response); + #ifdef __cplusplus } #endif \ No newline at end of file diff --git a/ot_br/components/ot_br_web_api/ot_br_web_api_coap_client.c b/ot_br/components/ot_br_web_api/ot_br_web_api_coap_client.c index eb03150..3e6ac47 100644 --- a/ot_br/components/ot_br_web_api/ot_br_web_api_coap_client.c +++ b/ot_br/components/ot_br_web_api/ot_br_web_api_coap_client.c @@ -17,6 +17,15 @@ static otCoapCode s_last_response_code = 0; static SemaphoreHandle_t s_diag_sem = NULL; static char s_resolved_omr[48] = {0}; +// CoAP-Testing +typedef struct { + otCoapCode code; + char payload[256]; + bool has_payload; +} ot_br_coap_response_t; +static ot_br_coap_response_t s_generic_response; + + static void coap_response_handler(void *ctx, otMessage *message, const otMessageInfo *info, otError result) { @@ -164,4 +173,87 @@ esp_err_t ot_br_resolve_omr_address(const char *rloc_address_str, char *out, siz strncpy(out, s_resolved_omr, out_len - 1); out[out_len - 1] = '\0'; return ESP_OK; +} + +// +// CoAP-Testing +// +static void generic_response_handler(void *ctx, otMessage *message, + const otMessageInfo *info, otError result) +{ + if (result == OT_ERROR_NONE && message) { + s_generic_response.code = otCoapMessageGetCode(message); + uint16_t offset = otMessageGetOffset(message); + uint16_t length = otMessageGetLength(message) - offset; + if (length > 0 && length < sizeof(s_generic_response.payload)) { + otMessageRead(message, offset, s_generic_response.payload, length); + s_generic_response.payload[length] = '\0'; + s_generic_response.has_payload = true; + } else { + s_generic_response.has_payload = false; + } + } else { + s_generic_response.code = 0; + s_generic_response.has_payload = false; + } + xSemaphoreGive(s_response_sem); +} + +esp_err_t ot_br_coap_generic_request(const char *address_str, otCoapCode method, + const char *uri_path, const char *payload, + ot_br_coap_response_t *out_response) +{ + otInstance *instance = esp_openthread_get_instance(); + xSemaphoreTake(s_response_sem, 0); + + esp_openthread_lock_acquire(portMAX_DELAY); + + otMessage *message = otCoapNewMessage(instance, NULL); + if (!message) { + esp_openthread_lock_release(); + return ESP_ERR_NO_MEM; + } + + otCoapType type = OT_COAP_TYPE_CONFIRMABLE; + otCoapMessageInit(message, type, method); + otCoapMessageGenerateToken(message, OT_COAP_DEFAULT_TOKEN_LENGTH); + otCoapMessageAppendUriPathOptions(message, uri_path); + + if (payload && strlen(payload) > 0 && + (method == OT_COAP_CODE_PUT || method == OT_COAP_CODE_POST)) { + otCoapMessageAppendContentFormatOption(message, OT_COAP_OPTION_CONTENT_FORMAT_JSON); + otCoapMessageSetPayloadMarker(message); + otMessageAppend(message, payload, strlen(payload)); + } + + otMessageInfo msgInfo; + memset(&msgInfo, 0, sizeof(msgInfo)); + otError addr_err = otIp6AddressFromString(address_str, &msgInfo.mPeerAddr); + if (addr_err != OT_ERROR_NONE) { + otMessageFree(message); + esp_openthread_lock_release(); + return ESP_ERR_INVALID_ARG; + } + msgInfo.mPeerPort = OT_DEFAULT_COAP_PORT; + + otCoapTxParameters tx_params = { + .mAckTimeout = 2000, + .mAckRandomFactorNumerator = 3, + .mAckRandomFactorDenominator = 2, + .mMaxRetransmit = 2, + }; + + otError err = otCoapSendRequestWithParameters(instance, message, &msgInfo, + generic_response_handler, NULL, &tx_params); + esp_openthread_lock_release(); + + if (err != OT_ERROR_NONE) { + return ESP_FAIL; + } + if (xSemaphoreTake(s_response_sem, pdMS_TO_TICKS(8000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + *out_response = s_generic_response; + return ESP_OK; } \ No newline at end of file diff --git a/ot_br/components/ot_br_web_api/ot_br_web_api_command.c b/ot_br/components/ot_br_web_api/ot_br_web_api_command.c index 19ceead..0cf737c 100644 --- a/ot_br/components/ot_br_web_api/ot_br_web_api_command.c +++ b/ot_br/components/ot_br_web_api/ot_br_web_api_command.c @@ -1,4 +1,5 @@ #include +#include "openthread/coap.h" #include "openthread/udp.h" #include "openthread/ip6.h" #include "openthread/thread.h" @@ -193,4 +194,87 @@ esp_err_t ot_br_coap_light_handler(httpd_req_t *req) cJSON_AddBoolToObject(j, "success", true); send_json(req, j); return ESP_OK; +} + +// +// CoAP-Testing-Command +// + +static otCoapCode method_from_string(const char *m) +{ + if (strcmp(m, "GET") == 0) return OT_COAP_CODE_GET; + if (strcmp(m, "PUT") == 0) return OT_COAP_CODE_PUT; + if (strcmp(m, "POST") == 0) return OT_COAP_CODE_POST; + if (strcmp(m, "DELETE") == 0) return OT_COAP_CODE_DELETE; + return OT_COAP_CODE_GET; +} + +// POST /api/thread/coap-request +// Body: {"address":"...", "method":"GET|PUT|POST|DELETE", "path":"light", "payload":"{...}"} +esp_err_t ot_br_coap_generic_handler(httpd_req_t *req) +{ + if (!ot_br_web_api_check_auth(req)) return ESP_OK; + + char *body = read_body(req); + if (!body) { + send_json_error(req, "400 Bad Request", "missing body"); + return ESP_OK; + } + cJSON *json = cJSON_Parse(body); + free(body); + if (!json) { + send_json_error(req, "400 Bad Request", "invalid JSON"); + return ESP_OK; + } + + cJSON *addr_item = cJSON_GetObjectItem(json, "address"); + cJSON *method_item = cJSON_GetObjectItem(json, "method"); + cJSON *path_item = cJSON_GetObjectItem(json, "path"); + cJSON *payload_item = cJSON_GetObjectItem(json, "payload"); + + if (!cJSON_IsString(addr_item) || !cJSON_IsString(path_item)) { + cJSON_Delete(json); + send_json_error(req, "400 Bad Request", "address and path required"); + return ESP_OK; + } + + char address[48]; + strncpy(address, addr_item->valuestring, sizeof(address) - 1); + address[sizeof(address) - 1] = '\0'; + + char path[64]; + strncpy(path, path_item->valuestring, sizeof(path) - 1); + path[sizeof(path) - 1] = '\0'; + + otCoapCode method = cJSON_IsString(method_item) + ? method_from_string(method_item->valuestring) : OT_COAP_CODE_GET; + + char payload[256] = {0}; + if (cJSON_IsString(payload_item)) { + strncpy(payload, payload_item->valuestring, sizeof(payload) - 1); + } + cJSON_Delete(json); + + // Adresse ueber OMR aufloesen, mit RLOC-Fallback (wie bei /coap-light) + char resolved_address[48]; + esp_err_t resolve_err = ot_br_resolve_omr_address(address, resolved_address, sizeof(resolved_address)); + const char *target = (resolve_err == ESP_OK) ? resolved_address : address; + + ot_br_coap_response_t response; + esp_err_t err = ot_br_coap_generic_request(target, method, path, payload, &response); + + if (err != ESP_OK) { + const char *msg = (err == ESP_ERR_TIMEOUT) ? "Geraet antwortet nicht (Timeout)" : "CoAP-Request fehlgeschlagen"; + send_json_error(req, "504 Gateway Timeout", msg); + return ESP_OK; + } + + cJSON *j = cJSON_CreateObject(); + cJSON_AddBoolToObject(j, "success", true); + cJSON_AddNumberToObject(j, "code", response.code); + if (response.has_payload) { + cJSON_AddStringToObject(j, "payload", response.payload); + } + send_json(req, j); + return ESP_OK; } \ No newline at end of file diff --git a/ot_br/components/ot_br_web_api/ot_br_web_api_handlers.c b/ot_br/components/ot_br_web_api/ot_br_web_api_handlers.c index 3c91d1c..6fc1831 100644 --- a/ot_br/components/ot_br_web_api/ot_br_web_api_handlers.c +++ b/ot_br/components/ot_br_web_api/ot_br_web_api_handlers.c @@ -565,6 +565,8 @@ void ot_br_web_api_register_handlers(httpd_handle_t server) { "/api/thread/send-command", HTTP_POST, ot_br_command_send_handler, NULL }, // CoAP Light Control { "/api/thread/coap-light", HTTP_POST, ot_br_coap_light_handler, NULL }, + // CoAP-Testing-Command + { "/api/thread/coap-request", HTTP_POST, ot_br_coap_generic_handler, NULL }, }; for (size_t i = 0; i < sizeof(uris) / sizeof(uris[0]); i++) { diff --git a/web/src/components/CoapTester.tsx b/web/src/components/CoapTester.tsx new file mode 100644 index 0000000..d9fcf92 --- /dev/null +++ b/web/src/components/CoapTester.tsx @@ -0,0 +1,130 @@ +import { $, component$, useSignal, useVisibleTask$ } from '@qwik.dev/core'; +import { type Neighbor, apiGet, apiPost } from '../lib/api'; + +interface NeighborWithAddr extends Neighbor { + rloc_address: string; +} + +type CoapMethod = 'GET' | 'PUT' | 'POST' | 'DELETE'; + +export default component$(() => { + const neighbors = useSignal([]); + const selectedExtMac = useSignal(''); + const method = useSignal('GET'); + const path = useSignal('light'); + const payload = useSignal(''); + const response = useSignal(''); + const sending = useSignal(false); + + useVisibleTask$(() => { + payload.value = '{"on":true,"r":0,"g":255,"b":0}'; + const fetchNeighbors = async () => { + try { + neighbors.value = await apiGet('/thread/neighbors'); + } catch (e) { + console.error(e); + } + }; + fetchNeighbors(); + const interval = setInterval(fetchNeighbors, 5000); + return () => clearInterval(interval); + }); + + const sendRequest = $(async () => { + if (!selectedExtMac.value) { + response.value = 'Bitte Gerät auswählen'; + return; + } + + const freshNeighbors = + await apiGet('/thread/neighbors'); + const target = freshNeighbors.find( + (n) => n.ext_mac === selectedExtMac.value, + ); + if (!target) { + response.value = 'Gerät nicht mehr im Netzwerk gefunden'; + return; + } + + const token = localStorage.getItem('ot_br_setup_token') ?? ''; + sending.value = true; + response.value = 'Sende...'; + + try { + const result = await apiPost<{ + success: boolean; + code: number; + payload?: string; + }>('/thread/coap-request', token, { + address: target.rloc_address, + method: method.value, + path: path.value, + payload: + method.value === 'PUT' || method.value === 'POST' + ? payload.value + : undefined, + }); + response.value = `Code ${result.code}${result.payload ? `: ${result.payload}` : ' (kein Payload)'}`; + } catch (e) { + response.value = e instanceof Error ? e.message : 'Fehler'; + } finally { + sending.value = false; + } + }); + + return ( +
+

CoAP-Test-Client

+ + + +
+ + { + path.value = (e.target as HTMLInputElement).value; + }} + /> +
+ + {(method.value === 'PUT' || method.value === 'POST') && ( + )} {response.value &&
{response.value}
} diff --git a/web/src/components/CommissionerPanel.tsx b/web/src/components/CommissionerPanel.tsx index 348e94c..d77aa7b 100644 --- a/web/src/components/CommissionerPanel.tsx +++ b/web/src/components/CommissionerPanel.tsx @@ -43,7 +43,7 @@ export default component$(() => { onClick$={startCommissioner} disabled={status.value === 'starting'} > - {status.value === 'starting' ? 'Starte...' : 'Commissioner starten'} + {status.value === 'starting' ? 'Starting...' : 'Start Commissioner'} )} {status.value === 'active' && (