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.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 new file mode 100644 index 0000000..09750d4 --- /dev/null +++ b/ot_br/components/ot_br_web_api/include/ot_br_web_api_coap_client.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esp_err.h" +#include "openthread/coap.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); + +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/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..3e6ac47 --- /dev/null +++ b/ot_br/components/ot_br_web_api/ot_br_web_api_coap_client.c @@ -0,0 +1,259 @@ +#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}; + +// 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) +{ + 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; +} + +// +// 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 2cd25cc..c54acee 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" @@ -12,6 +13,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 @@ -135,4 +138,163 @@ esp_err_t ot_br_command_send_handler(httpd_req_t *req) 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); + + // When the first request fails with a timeout + // try again with a fresh resolved address (re-joined device may have a new RLOC) + if (err == ESP_ERR_TIMEOUT) { + ESP_LOGW(TAG, "First CoAP attempt failed (Timeout), retrying with a fresh address..."); + char retry_address[48]; + esp_err_t retry_resolve = ot_br_resolve_omr_address(address, retry_address, sizeof(retry_address)); + const char *retry_target = (retry_resolve == ESP_OK) ? retry_address : address; + err = ot_br_coap_light_set(retry_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); + 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); + + // When the first request fails with a timeout + // try again with a fresh resolved address (re-joined device may have a new RLOC) + if (err == ESP_ERR_TIMEOUT) { + ESP_LOGW(TAG, "First try failed, attempting again with a fresh address..."); + char retry_address[48]; + esp_err_t retry_resolve = ot_br_resolve_omr_address(address, retry_address, sizeof(retry_address)); + const char *retry_target = (retry_resolve == ESP_OK) ? retry_address : address; + err = ot_br_coap_generic_request(retry_target, method, path, payload, &response); + } + + if (err != ESP_OK) { + const char *msg = (err == ESP_ERR_TIMEOUT) ? "Device not responding (Timeout)" : "CoAP request failed"; + 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 88bffb2..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 @@ -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,10 @@ 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 }, + // 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/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..8dbc600 --- /dev/null +++ b/web/src/components/CoapLightControl.tsx @@ -0,0 +1,103 @@ +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 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) => { + 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/CoapTester.tsx b/web/src/components/CoapTester.tsx new file mode 100644 index 0000000..51049e7 --- /dev/null +++ b/web/src/components/CoapTester.tsx @@ -0,0 +1,131 @@ +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('{"on":true,"r":0,"g":255,"b":0}'); + const response = useSignal(''); + const sending = useSignal(false); + + useVisibleTask$(() => { + 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 = 'Please select a device'; + return; + } + + const freshNeighbors = + await apiGet('/thread/neighbors'); + const target = freshNeighbors.find( + (n) => n.ext_mac === selectedExtMac.value, + ); + if (!target) { + response.value = 'Device not found in the network'; + return; + } + + const token = localStorage.getItem('ot_br_setup_token') ?? ''; + sending.value = true; + response.value = + 'Sending (can take a moment during network synchronization)...'; + + 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' && ( diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index e3de987..1eef340 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -1,7 +1,8 @@ import { $, component$, useSignal, useVisibleTask$ } from '@qwik.dev/core'; import { apiGet } from '../lib/api'; +import CoapLightControl from './CoapLightControl'; +import CoapTester from './CoapTester'; import CommissionerPanel from './CommissionerPanel'; -import DeviceControl from './DeviceControl'; import DeviceInfo from './DeviceInfo'; import NeighborList from './NeighborList'; import OtaBanner from './OtaBanner'; @@ -58,7 +59,8 @@ export default component$(() => { - + + ); } diff --git a/web/src/components/DeviceControl.tsx b/web/src/components/DeviceControl.tsx deleted file mode 100644 index 0078da2..0000000 --- a/web/src/components/DeviceControl.tsx +++ /dev/null @@ -1,87 +0,0 @@ -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 selectedAddress = useSignal(''); - const sending = useSignal(false); - const message = useSignal(''); - - useVisibleTask$(async () => { - neighbors.value = await apiGet('/thread/neighbors'); - }); - - const sendCommand = $(async (command: string) => { - if (!selectedAddress.value) { - message.value = 'Please select a device first'; - return; - } - const token = localStorage.getItem('ot_br_setup_token') ?? ''; - sending.value = true; - try { - await apiPost('/thread/send-command', token, { - address: selectedAddress.value, - command, - }); - message.value = `"${command}" sent to ${selectedAddress.value}`; - } catch (e) { - message.value = e instanceof Error ? e.message : 'Failed to send command'; - } finally { - sending.value = false; - } - }); - - return ( -
-

Device control

- {neighbors.value.length === 0 ? ( -

No devices found in the network.

- ) : ( - <> - -
- - - -
- - )} - {message.value &&

{message.value}

} -
- ); -}); diff --git a/web/src/layouts/Layout.astro b/web/src/layouts/Layout.astro index b664fcd..d127fdf 100644 --- a/web/src/layouts/Layout.astro +++ b/web/src/layouts/Layout.astro @@ -136,6 +136,20 @@ const { title } = Astro.props; border-bottom: 1px solid var(--border); } + textarea { + width: 100%; + min-height: 80px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem; + color: var(--text); + font-family: monospace; + font-size: 0.85rem; + margin-top: 0.5rem; + } + + .muted { color: var(--text-muted); font-size: 0.8rem; @@ -199,4 +213,19 @@ const { title } = Astro.props; height: 100%; transition: width 0.3s ease; } + + .coap-form-row { + display: flex; + gap: 0.5rem; + } + + .coap-response { + background: var(--surface-hover); + padding: 0.75rem; + border-radius: 6px; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-all; + margin-top: 0.75rem; + } \ No newline at end of file