From 2d55d7f9d1116280932e1bb2917acf0dfa843c31 Mon Sep 17 00:00:00 2001 From: Tony Date: Sun, 9 Aug 2026 07:06:20 +0800 Subject: [PATCH] Add Matter DALI support and enhance IPv6 handling This commit fix DALI feedback on matter bridge. - Introduced MatterLevelFeedback structure to manage DALI level feedback. - Implemented MapDaliLevelToMatter function to map DALI levels to Matter states. - Added MatterDaliAction and ResolveMatterDaliAction for handling DALI actions. - Created MatterColorTemperatureWritePlan and ResolveMatterColorTemperatureWrites for color temperature management. - Enhanced GatewayMatterBridge to handle DALI commands and transitions, including a new command task for processing DALI actions. - Implemented factory reset functionality for clearing Matter-related NVS namespaces. - Improved GatewayNetworkService to refresh Matter IPv6 addresses upon network events and ensure proper link-local address promotion. - Added tests for new functionalities in gateway_matter_plan_test.cpp to validate DALI level mapping and action resolution. Signed-off-by: Tony --- README.md | 33 ++ apps/gateway/main/app_main.cpp | 32 ++ apps/gateway/sdkconfig | 6 +- apps/gateway/sdkconfig.defaults | 2 + .../gateway_bridge/src/gateway_bridge.cpp | 3 +- .../gateway_matter/include/gateway_matter.hpp | 28 ++ .../include/gateway_matter_plan.hpp | 37 ++ .../gateway_matter/src/gateway_matter.cpp | 420 +++++++++++++++--- .../src/gateway_matter_plan.cpp | 36 ++ .../tests/gateway_matter_plan_test.cpp | 48 ++ .../include/gateway_network.hpp | 15 + .../gateway_network/src/gateway_network.cpp | 231 ++++++++++ 12 files changed, 816 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index de0245e..e3ae7eb 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,33 @@ a GPIO allows a long press to start ESP-Touch SmartConfig and reopen the Matter commissioning window. The long-press duration and active level are configurable beside the GPIO in `main/Kconfig.projbuild`. +The gateway creates an IPv6 link-local address whenever Ethernet or Wi-Fi joins +the link and enables IPv6 router-advertisement autoconfiguration. This is part +of the custom network owner rather than ESP-Matter's network commissioning +driver. Because the W5500 link can become usable after CHIP starts, the network +owner waits for the preferred link-local address and re-announces it after +CHIP's event handler is ready, and repeats that recovery after reconnects. The +resulting `IP_EVENT_GOT_IP6` event lets Apple Home and other Matter controllers +establish their normal operational CASE sessions after the commissioning +connection closes. +If a W5500 link leaves its deterministic MAC-derived link-local address in the +tentative state, the refresh task allows normal duplicate-address detection to +settle first and then promotes that address through ESP-IDF's public IPv6 API. +This keeps the operational CASE step reachable instead of allowing BLE/PASE and +NOC installation to succeed only for Apple Home to roll the temporary fabric +back when operational discovery times out. + +Matter attribute callbacks never wait for physical DALI frames. On/off, level, +color-temperature, XY, and hue/saturation changes are coalesced by a dedicated +internal-RAM command task, which keeps CHIP acknowledgements and subscription +feedback responsive while a DALI transaction is in flight. Transition updates +from `MoveToColorTemperature` are collapsed at command ingress: the Matter +attribute transition and reports continue normally, while DALI receives the +final physical target once instead of every intermediate step. Native DT8 +color-temperature endpoints send only the Tc sequence; the longer RGBWAF +sequence is reserved for extended-color endpoints whose configured physical +method requires it. + The network service accepts Lua-style raw gateway frames on UDP port `2020` and, when enabled, TCP port `2020`. It also accepts JSON control frames on the same ports: @@ -222,6 +249,12 @@ certificate SHA-256 before committing the DAC/private/public keys, PAI, Certification Declaration, SPAKE2+ data, and device identity. Its response reports `factoryData.restartRequired`; `matter_restart` schedules a delayed reboot so the transport can deliver its response before ESP-Matter reloads the providers. +The confirmed `matter_factory_reset` action clears fabrics, operational +sessions, subscriptions, Matter attributes/counters, ESP-Matter bridge records, +and DaliMaster Matter endpoint bindings/configuration, then restarts. It erases +only runtime namespaces and deliberately preserves `chip-factory`, including +the DAC/private key, PAI, Certification Declaration, SPAKE2+ data, and device +identity. These actions are available through the active BLE/USB/IP/KNX/cloud bridge transport as well as the local HTTP `/bridge` endpoint, so Matter setup does not require a working IP profile. diff --git a/apps/gateway/main/app_main.cpp b/apps/gateway/main/app_main.cpp index 8bc8007..30b23c6 100644 --- a/apps/gateway/main/app_main.cpp +++ b/apps/gateway/main/app_main.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -607,6 +608,7 @@ std::unique_ptr s_controller; std::unique_ptr s_bridge; std::unique_ptr s_network; std::unique_ptr s_matter_bridge; +std::atomic_bool s_matter_factory_reset_scheduled{false}; std::unique_ptr s_ble_bridge; std::unique_ptr s_uart0_control_bridge; std::unique_ptr s_usb_setup_bridge; @@ -1393,6 +1395,30 @@ extern "C" void app_main(void) { err = s_matter_bridge->resetConfiguration(*gateway_id); } else if (action == "matter_factory_data") { err = s_matter_bridge->installFactoryData(body); + } else if (action == "matter_factory_reset") { + if (s_matter_factory_reset_scheduled.exchange(true)) { + err = ESP_ERR_INVALID_STATE; + } else { + err = s_matter_bridge->prepareFactoryReset(body); + if (err == ESP_OK) { + const BaseType_t created = xTaskCreate( + [](void*) { + vTaskDelay(pdMS_TO_TICKS(750)); + const esp_err_t reset_err = esp_matter::factory_reset(); + if (reset_err != ESP_OK) { + ESP_LOGE(kTag, "Matter factory reset failed: %s", + esp_err_to_name(reset_err)); + s_matter_factory_reset_scheduled.store(false); + } + vTaskDelete(nullptr); + }, + "matter_factory_reset", 4096, nullptr, 3, nullptr); + if (created != pdPASS) err = ESP_ERR_NO_MEM; + } + if (err != ESP_OK) { + s_matter_factory_reset_scheduled.store(false); + } + } } else if (action == "matter_restart") { const BaseType_t created = xTaskCreate( [](void*) { @@ -1620,6 +1646,12 @@ extern "C" void app_main(void) { const esp_err_t matter_err = s_matter_bridge->start(); LogHeapSnapshot("after Matter"); ESP_ERROR_CHECK(matter_err); + if (s_network != nullptr) { + // gateway_network starts before CHIP and may not have a live link yet. + // Wait for a preferred link-local address, then replay it after CHIP has + // registered its event handler so operational Matter discovery starts. + s_network->refreshMatterIpv6(); + } } // ESP-Matter needs several contiguous internal allocations for the CHIP diff --git a/apps/gateway/sdkconfig b/apps/gateway/sdkconfig index 9286c03..3ae29c8 100644 --- a/apps/gateway/sdkconfig +++ b/apps/gateway/sdkconfig @@ -2377,9 +2377,11 @@ CONFIG_LWIP_AUTOIP_MAX_CONFLICTS=9 CONFIG_LWIP_AUTOIP_RATE_LIMIT_INTERVAL=20 CONFIG_LWIP_IPV4=y CONFIG_LWIP_IPV6=y -# CONFIG_LWIP_IPV6_AUTOCONFIG is not set +CONFIG_LWIP_IPV6_AUTOCONFIG=y CONFIG_LWIP_IPV6_NUM_ADDRESSES=3 # CONFIG_LWIP_IPV6_FORWARD is not set +CONFIG_LWIP_IPV6_RDNSS_MAX_DNS_SERVERS=0 +# CONFIG_LWIP_IPV6_DHCP6 is not set CONFIG_LWIP_NETIF_STATUS_CALLBACK=y CONFIG_LWIP_NETIF_LOOPBACK=y CONFIG_LWIP_LOOPBACK_MAX_PBUFS=8 @@ -3116,7 +3118,7 @@ CONFIG_CHIP_ENABLE_PAIRING_AUTOSTART=y # General Options # CONFIG_CHIP_PROJECT_CONFIG="" -CONFIG_CHIP_TASK_STACK_SIZE=8192 +CONFIG_CHIP_TASK_STACK_SIZE=16384 CONFIG_CHIP_TASK_PRIORITY=1 CONFIG_MAX_EVENT_QUEUE_SIZE=40 # CONFIG_ENABLE_EXTENDED_DISCOVERY is not set diff --git a/apps/gateway/sdkconfig.defaults b/apps/gateway/sdkconfig.defaults index 61cd124..2b982e4 100644 --- a/apps/gateway/sdkconfig.defaults +++ b/apps/gateway/sdkconfig.defaults @@ -64,6 +64,8 @@ CONFIG_ESP_MATTER_AGGREGATOR_ENDPOINT_COUNT=1 CONFIG_ESP_MATTER_NVS_PART_NAME="nvs" CONFIG_ESP_MATTER_BRIDGE_INFO_PART_NAME="nvs" CONFIG_CUSTOM_NETWORK_CONFIG=y +CONFIG_LWIP_IPV6=y +CONFIG_LWIP_IPV6_AUTOCONFIG=y # Matter uses the already-configured W5500 interface under CustomNetworkConfig. # gateway_network owns Wi-Fi station credentials and starts ESP-Touch # SmartConfig only after the button is held; CHIP must not initialize Wi-Fi. diff --git a/components/gateway_bridge/src/gateway_bridge.cpp b/components/gateway_bridge/src/gateway_bridge.cpp index d749a37..2d2a342 100644 --- a/components/gateway_bridge/src/gateway_bridge.cpp +++ b/components/gateway_bridge/src/gateway_bridge.cpp @@ -4830,7 +4830,8 @@ GatewayBridgeHttpResponse GatewayBridgeService::handlePost( if (action == "matter_open_commissioning" || action == "matter_close_commissioning" || action == "matter_rescan" || action == "matter_config" || action == "matter_config_reset" || - action == "matter_factory_data" || action == "matter_restart") { + action == "matter_factory_data" || action == "matter_factory_reset" || + action == "matter_restart") { if (!config_.matter_action_handler) { return ErrorResponse(ESP_ERR_NOT_SUPPORTED, "Matter control is not available"); } diff --git a/components/gateway_matter/include/gateway_matter.hpp b/components/gateway_matter/include/gateway_matter.hpp index aec0bac..cd1c191 100644 --- a/components/gateway_matter/include/gateway_matter.hpp +++ b/components/gateway_matter/include/gateway_matter.hpp @@ -18,6 +18,7 @@ #include "esp_matter_bridge.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "system/SystemLayer.h" namespace gateway { @@ -28,6 +29,8 @@ struct GatewayMatterBridgeConfig { uint32_t scan_address_delay_ms{20}; uint32_t scan_task_stack_size{8192}; UBaseType_t scan_task_priority{3}; + uint32_t command_task_stack_size{6144}; + UBaseType_t command_task_priority{4}; int wifi_provision_button_gpio{-1}; bool wifi_provision_button_active_low{true}; uint32_t wifi_provision_long_press_ms{5000}; @@ -53,11 +56,19 @@ class GatewayMatterBridge { esp_err_t rescanDaliDevices(); esp_err_t applyConfiguration(uint8_t gateway_id, std::string_view patch); esp_err_t resetConfiguration(uint8_t gateway_id); + esp_err_t prepareFactoryReset(std::string_view payload); esp_err_t installFactoryData(std::string_view payload); std::string statusJson(std::optional gateway_id = std::nullopt) const; std::string onboardingJson() const; private: + enum class PendingColorAction : uint8_t { + none = 0, + temperature = 1, + xy = 2, + hueSaturation = 3, + }; + struct Binding { GatewayMatterBridge* owner{nullptr}; uint8_t channel_index{0}; @@ -71,8 +82,16 @@ class GatewayMatterBridge { uint16_t endpoint_id{0}; uint16_t current_x{32768}; uint16_t current_y{32768}; + uint8_t current_level{254}; uint8_t current_hue{0}; uint8_t current_saturation{0}; + bool current_on{true}; + std::optional pending_on; + std::optional pending_level; + std::optional pending_color_temperature; + std::optional color_temperature_transition_target; + TickType_t color_temperature_transition_deadline{0}; + PendingColorAction pending_color_action{PendingColorAction::none}; bool suppress_attribute_write{false}; esp_matter_bridge::device_t* matter_device{nullptr}; }; @@ -86,11 +105,15 @@ class GatewayMatterBridge { static esp_err_t Identify(esp_matter::identification::callback_type_t type, uint16_t endpoint_id, uint8_t effect_id, uint8_t effect_variant, void* private_data); + static esp_err_t MoveToColorTemperatureCommand( + const chip::app::ConcreteCommandPath& command_path, + chip::TLV::TLVReader& tlv_data, void* opaque_ptr); static esp_err_t AddDeviceType(esp_matter::endpoint_t* endpoint, uint32_t device_type_id, void* private_data); static void MatterEvent(const chip::DeviceLayer::ChipDeviceEvent* event, intptr_t argument); static void ScanTaskEntry(void* argument); + static void CommandTaskEntry(void* argument); static void ButtonTaskEntry(void* argument); static void ApplyStatusWork(intptr_t argument); static void OpenCommissioningWindowWork(intptr_t argument); @@ -105,6 +128,7 @@ class GatewayMatterBridge { esp_err_t storeConfiguration(const MatterChannelConfiguration& configuration) const; esp_err_t configureProvisioningButton(); void scanDaliDevices(); + void commandTaskLoop(); esp_err_t reconcileEndpoints(); void buttonTaskLoop(); void handleStatusUpdate(const DaliGatewayStatusUpdate& update); @@ -112,10 +136,13 @@ class GatewayMatterBridge { Binding* findBinding(uint16_t endpoint_id) const; esp_err_t createBinding(const MatterEndpointAllocation& allocation, uint16_t device_type_mask, uint8_t initial_level = 0); + void configureColorCommandCallbacks(Binding& binding); esp_err_t removeBinding(size_t index); esp_err_t storeBinding(const Binding& binding) const; std::unique_ptr readBinding(uint16_t endpoint_id) const; void updateMatterLevel(Binding& binding, uint8_t level); + esp_err_t scheduleDaliAction(Binding& binding); + void applyPendingDaliActions(); void requestWifiProvisioning(); DaliDomainService& dali_domain_; @@ -128,6 +155,7 @@ class GatewayMatterBridge { MatterEndpointPlan endpoint_plan_; std::string last_apply_error_; TaskHandle_t scan_task_{nullptr}; + TaskHandle_t command_task_{nullptr}; TaskHandle_t button_task_{nullptr}; bool started_{false}; std::atomic_bool scan_in_progress_{false}; diff --git a/components/gateway_matter/include/gateway_matter_plan.hpp b/components/gateway_matter/include/gateway_matter_plan.hpp index 14c9e81..37eb2f9 100644 --- a/components/gateway_matter/include/gateway_matter_plan.hpp +++ b/components/gateway_matter/include/gateway_matter_plan.hpp @@ -74,11 +74,48 @@ struct MatterEndpointPlan { std::vector dropped; }; +struct MatterLevelFeedback { + bool on{false}; + std::optional current_level; +}; + +enum class MatterDaliActionKind : uint8_t { none = 0, off = 1, setLevel = 2 }; + +struct MatterDaliAction { + MatterDaliActionKind kind{MatterDaliActionKind::none}; + uint8_t level{254}; +}; + +struct MatterColorTemperatureWritePlan { + bool native_temperature{false}; + bool rgbcw{false}; +}; + MatterEndpointPlan BuildMatterEndpointPlan( const std::vector& candidates, const std::vector& configurations, size_t capacity); +// Matter CurrentLevel is nullable and constrained to 1..254. DALI level zero +// therefore updates OnOff only and must retain the last nonzero Matter level. +MatterLevelFeedback MapDaliLevelToMatter(uint8_t level); + +// A single Matter command can update OnOff and CurrentLevel several times as +// the standard level-control server applies its on/off effect. Resolve the +// settled values to one DALI action so an Off command cannot finish with a +// restored CurrentLevel frame that turns the light back on. +MatterDaliAction ResolveMatterDaliAction(std::optional requested_on, + std::optional requested_level, + bool settled_on, + uint8_t retained_level); + +// Temperature-only endpoints represent native DALI DT8 Tc gear and must not +// also receive the much longer RGBWAF sequence. Extended-color endpoints keep +// following their configured physical color method. +MatterColorTemperatureWritePlan ResolveMatterColorTemperatureWrites( + MatterEndpointType endpoint_type, + std::optional color_method); + const char* MatterTargetKindName(MatterTargetKind kind); const char* MatterEndpointTypeName(MatterEndpointType type); const char* MatterColorMethodName(MatterColorMethod method); diff --git a/components/gateway_matter/src/gateway_matter.cpp b/components/gateway_matter/src/gateway_matter.cpp index b4c0aff..23e96ab 100644 --- a/components/gateway_matter/src/gateway_matter.cpp +++ b/components/gateway_matter/src/gateway_matter.cpp @@ -39,6 +39,7 @@ constexpr char kFactoryNamespace[] = "chip-factory"; constexpr char kFactoryProfile[] = "matter-test-paa"; constexpr char kFactoryInstallConfirmation[] = "install-matter-test-factory-data"; +constexpr char kFactoryResetConfirmation[] = "reset-matter-runtime-data"; constexpr char kFactoryPaiSha256[] = "27cf4e8cbf73f8fac2b9ab78b7d0fe59a10100b8e9a2df136a85b41a5d271bae"; constexpr char kFactoryCertificationDeclarationSha256[] = @@ -52,6 +53,8 @@ constexpr uint8_t kBindingVersion = 2; constexpr uint8_t kConfigurationVersion = 1; constexpr uint16_t kAllDaliDeviceTypesMask = 0x01FF; constexpr uint32_t kCommissioningWindowSeconds = 300; +constexpr uint32_t kMatterCommandSettleDelayMs = 15; +constexpr uint32_t kMatterTransitionDeadlineMarginMs = 1000; GatewayMatterBridge* s_active_matter_bridge = nullptr; static_assert(MAX_BRIDGED_DEVICE_COUNT == 82, "DaliMaster Matter capacity expects 82 bridge slots"); @@ -183,6 +186,17 @@ bool HasNvsBlob(nvs_handle_t handle, const char* key) { return nvs_get_blob(handle, key, nullptr, &length) == ESP_OK && length > 0; } +esp_err_t EraseNvsNamespace(const char* partition, const char* nvs_namespace) { + nvs_handle_t handle = 0; + esp_err_t err = nvs_open_from_partition(partition, nvs_namespace, + NVS_READWRITE, &handle); + if (err != ESP_OK) return err; + err = nvs_erase_all(handle); + if (err == ESP_OK) err = nvs_commit(handle); + nvs_close(handle); + return err; +} + std::string ReadNvsString(nvs_handle_t handle, const char* key) { size_t length = 0; if (nvs_get_str(handle, key, nullptr, &length) != ESP_OK || length <= 1) { @@ -427,11 +441,28 @@ esp_err_t GatewayMatterBridge::start() { MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT))); return ESP_ERR_NO_MEM; } + if (xTaskCreate(CommandTaskEntry, "matter_dali_cmd", + config_.command_task_stack_size, this, + config_.command_task_priority, &command_task_) != pdPASS) { + vTaskDelete(scan_task_); + scan_task_ = nullptr; + scan_in_progress_.store(false, std::memory_order_release); + ESP_LOGE(kTag, + "failed to allocate Matter DALI command stack bytes=%u free=%u largest=%u", + static_cast(config_.command_task_stack_size), + static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL | + MALLOC_CAP_8BIT)), + static_cast(heap_caps_get_largest_free_block( + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT))); + return ESP_ERR_NO_MEM; + } const esp_err_t resume_err = resumeBindings(); if (resume_err != ESP_OK) { vTaskDelete(scan_task_); scan_task_ = nullptr; + vTaskDelete(command_task_); + command_task_ = nullptr; scan_in_progress_.store(false, std::memory_order_release); ESP_LOGE(kTag, "failed to resume DALI bindings: %s", esp_err_to_name(resume_err)); return resume_err; @@ -443,6 +474,8 @@ esp_err_t GatewayMatterBridge::start() { if (button_err != ESP_OK) { vTaskDelete(scan_task_); scan_task_ = nullptr; + vTaskDelete(command_task_); + command_task_ = nullptr; scan_in_progress_.store(false, std::memory_order_release); ESP_LOGE(kTag, "failed to configure Matter provisioning button: %s", esp_err_to_name(button_err)); @@ -559,6 +592,7 @@ esp_err_t GatewayMatterBridge::resumeBindings() { } esp_matter::endpoint::enable(binding->matter_device->endpoint); binding->suppress_attribute_write = false; + configureColorCommandCallbacks(*binding); { std::lock_guard guard(bindings_mutex_); bindings_.push_back(std::move(binding)); @@ -582,6 +616,10 @@ esp_err_t GatewayMatterBridge::createBinding( binding->color_method = allocation.color_method; binding->dali_device_type_mask = device_type_mask; binding->matter_device_type = MatterDeviceType(allocation.type); + binding->current_on = initial_level > 0 && initial_level <= 254; + if (binding->current_on) { + binding->current_level = initial_level; + } // Endpoint enablement may restore OnOff/Level attributes and invoke the // application callback synchronously. Suppress those writes until the new // endpoint is fully registered; updateMatterLevel() applies the discovered @@ -605,6 +643,7 @@ esp_err_t GatewayMatterBridge::createBinding( } esp_matter::endpoint::enable(binding->matter_device->endpoint); binding->suppress_attribute_write = false; + configureColorCommandCallbacks(*binding); Binding* created = binding.get(); { std::lock_guard guard(bindings_mutex_); @@ -852,6 +891,24 @@ void GatewayMatterBridge::ScanTaskEntry(void* argument) { vTaskDelete(nullptr); } +void GatewayMatterBridge::CommandTaskEntry(void* argument) { + static_cast(argument)->commandTaskLoop(); +} + +void GatewayMatterBridge::commandTaskLoop() { + ESP_LOGI(kTag, "Matter DALI command task started stack=%lu bytes", + static_cast(config_.command_task_stack_size)); + while (true) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + // Pair coupled attribute updates (for example X then Y) without adding + // visible delay to individual on/off and level commands. Long Matter + // transitions are collapsed at command ingress before reaching this task. + while (ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kMatterCommandSettleDelayMs)) > 0) { + } + applyPendingDaliActions(); + } +} + esp_err_t GatewayMatterBridge::rescanDaliDevices() { if (!started_) return ESP_ERR_INVALID_STATE; bool expected = false; @@ -975,6 +1032,33 @@ esp_err_t GatewayMatterBridge::resetConfiguration(uint8_t gateway_id) { return err; } +esp_err_t GatewayMatterBridge::prepareFactoryReset(std::string_view payload) { + cJSON* root = cJSON_ParseWithLength(payload.data(), payload.size()); + std::string confirmation; + const bool confirmed = + root != nullptr && cJSON_IsObject(root) && + JsonStringValue(root, "confirm", 1, 64, &confirmation) && + confirmation == kFactoryResetConfirmation; + cJSON_Delete(root); + if (!confirmed) return ESP_ERR_INVALID_ARG; + + // These namespaces contain DaliMaster's Matter endpoint plan and bridge + // bindings. The standard ESP-Matter factory reset below owns fabrics, + // sessions, attributes, counters, and its KVS. Never erase chip-factory: + // it contains the permanent DAC/PAI/CD and commissionable data. + ESP_RETURN_ON_ERROR(EraseNvsNamespace("nvs", kBindingNamespace), kTag, + "failed to clear Matter DALI bindings"); + ESP_RETURN_ON_ERROR(EraseNvsNamespace("nvs", kConfigurationNamespace), kTag, + "failed to clear Matter endpoint configuration"); + ESP_RETURN_ON_ERROR(EraseNvsNamespace(CONFIG_ESP_MATTER_NVS_PART_NAME, "node"), + kTag, "failed to clear legacy Matter node state"); + ESP_RETURN_ON_ERROR(esp_matter_bridge::factory_reset(), kTag, + "failed to clear ESP-Matter bridge state"); + ESP_LOGW(kTag, + "Matter runtime reset prepared; chip-factory credentials preserved"); + return ESP_OK; +} + esp_err_t GatewayMatterBridge::installFactoryData(std::string_view payload) { cJSON* root = cJSON_ParseWithLength(payload.data(), payload.size()); if (root == nullptr || !cJSON_IsObject(root)) { @@ -1466,68 +1550,121 @@ esp_err_t GatewayMatterBridge::AttributeUpdate( *value); } +esp_err_t GatewayMatterBridge::MoveToColorTemperatureCommand( + const chip::app::ConcreteCommandPath& command_path, + chip::TLV::TLVReader& tlv_data, void* opaque_ptr) { + (void)opaque_ptr; + chip::app::Clusters::ColorControl::Commands::MoveToColorTemperature:: + DecodableType command_data; + if (command_data.Decode(tlv_data) != CHIP_NO_ERROR) { + // Leave validation and the command response to the standard Matter + // handler, which receives its own copy of the TLV reader after this hook. + return ESP_OK; + } + auto* bridge = s_active_matter_bridge; + if (bridge == nullptr) return ESP_OK; + + std::lock_guard guard(bridge->bindings_mutex_); + const auto binding = std::find_if( + bridge->bindings_.begin(), bridge->bindings_.end(), + [&command_path](const auto& item) { + return item->endpoint_id == command_path.mEndpointId; + }); + if (binding == bridge->bindings_.end()) return ESP_OK; + + Binding& target = **binding; + target.color_temperature_transition_target = + command_data.colorTemperatureMireds; + const uint32_t transition_ms = + static_cast(command_data.transitionTime) * 100U; + target.color_temperature_transition_deadline = + xTaskGetTickCount() + pdMS_TO_TICKS( + transition_ms + + kMatterTransitionDeadlineMarginMs); + target.pending_color_temperature = command_data.colorTemperatureMireds; + target.pending_color_action = PendingColorAction::temperature; + ESP_LOGI(kTag, + "collapsed Matter color-temperature transition endpoint=%u target=%u " + "transition_ms=%lu", + target.endpoint_id, command_data.colorTemperatureMireds, + static_cast(transition_ms)); + return bridge->scheduleDaliAction(target); +} + +void GatewayMatterBridge::configureColorCommandCallbacks(Binding& binding) { + using namespace chip::app::Clusters; + if (binding.endpoint_type != MatterEndpointType::colorTemperature && + binding.endpoint_type != MatterEndpointType::color) { + return; + } + esp_matter::command_t* command = esp_matter::command::get( + binding.endpoint_id, ColorControl::Id, + ColorControl::Commands::MoveToColorTemperature::Id); + if (command == nullptr) { + ESP_LOGW(kTag, + "MoveToColorTemperature command unavailable endpoint=%u", + binding.endpoint_id); + return; + } + esp_matter::command::set_user_callback( + command, MoveToColorTemperatureCommand); +} + esp_err_t GatewayMatterBridge::handleAttributeUpdate( Binding& binding, uint32_t cluster_id, uint32_t attribute_id, const esp_matter_attr_val_t& value) { using namespace chip::app::Clusters; - bool accepted = true; - const int target = binding.target_address; - const MatterColorMethod method = binding.color_method.value_or(MatterColorMethod::both); + std::lock_guard guard(bindings_mutex_); if (cluster_id == OnOff::Id && attribute_id == OnOff::Attributes::OnOff::Id) { - accepted = value.val.b ? dali_domain_.on(binding.gateway_id, target) - : dali_domain_.off(binding.gateway_id, target); + binding.current_on = value.val.b; + binding.pending_on = value.val.b; + return scheduleDaliAction(binding); } else if (cluster_id == LevelControl::Id && attribute_id == LevelControl::Attributes::CurrentLevel::Id) { - accepted = dali_domain_.setBright(binding.gateway_id, target, - std::min(value.val.u8, 254)); + if (value.type != ESP_MATTER_VAL_TYPE_UINT8 && + value.type != ESP_MATTER_VAL_TYPE_NULLABLE_UINT8) { + return ESP_ERR_INVALID_ARG; + } + if (value.type == ESP_MATTER_VAL_TYPE_NULLABLE_UINT8 && + chip::app::NumericAttributeTraits::IsNullValue(value.val.u8)) { + return ESP_OK; + } + binding.current_level = std::clamp(value.val.u8, 1, 254); + binding.pending_level = binding.current_level; + return scheduleDaliAction(binding); } else if (cluster_id == ColorControl::Id && attribute_id == ColorControl::Attributes::ColorTemperatureMireds::Id) { - const bool native_ok = dali_domain_.setColTempRaw(binding.gateway_id, target, - value.val.u16); - const int kelvin = value.val.u16 == 0 ? 6500 : 1000000 / value.val.u16; - const double cool_ratio = std::clamp((kelvin - 2700.0) / (6500.0 - 2700.0), - 0.0, 1.0); - const bool rgbcw_ok = dali_domain_.setColourRGBCW( - binding.gateway_id, target, 0, 0, 0, - static_cast(std::round(254.0 * cool_ratio)), - static_cast(std::round(254.0 * (1.0 - cool_ratio)))); - accepted = native_ok && rgbcw_ok; + if (binding.color_temperature_transition_target.has_value()) { + const uint16_t target = *binding.color_temperature_transition_target; + if (value.val.u16 == target) { + binding.color_temperature_transition_target.reset(); + return ESP_OK; + } + const TickType_t now = xTaskGetTickCount(); + if (static_cast(now - + binding.color_temperature_transition_deadline) < + 0) { + return ESP_OK; + } + ESP_LOGW(kTag, + "Matter color-temperature transition timed out endpoint=%u " + "target=%u current=%u", + binding.endpoint_id, target, value.val.u16); + binding.color_temperature_transition_target.reset(); + } + binding.pending_color_temperature = value.val.u16; + binding.pending_color_action = PendingColorAction::temperature; + return scheduleDaliAction(binding); } else if (cluster_id == ColorControl::Id && attribute_id == ColorControl::Attributes::CurrentX::Id) { binding.current_x = value.val.u16; - bool xy_ok = true; - bool rgbcw_ok = true; - if (method != MatterColorMethod::rgbcw) { - xy_ok = dali_domain_.setColourXY(binding.gateway_id, target, - binding.current_x, binding.current_y); - } - if (method != MatterColorMethod::xy) { - int red = 0; - int green = 0; - int blue = 0; - XyToRgb(binding.current_x, binding.current_y, &red, &green, &blue); - rgbcw_ok = dali_domain_.setColourRGBCW(binding.gateway_id, target, - red, green, blue, 0, 0); - } - accepted = xy_ok && rgbcw_ok; + binding.pending_color_action = PendingColorAction::xy; + return scheduleDaliAction(binding); } else if (cluster_id == ColorControl::Id && attribute_id == ColorControl::Attributes::CurrentY::Id) { binding.current_y = value.val.u16; - bool xy_ok = true; - bool rgbcw_ok = true; - if (method != MatterColorMethod::rgbcw) { - xy_ok = dali_domain_.setColourXY(binding.gateway_id, target, - binding.current_x, binding.current_y); - } - if (method != MatterColorMethod::xy) { - int red = 0; - int green = 0; - int blue = 0; - XyToRgb(binding.current_x, binding.current_y, &red, &green, &blue); - rgbcw_ok = dali_domain_.setColourRGBCW(binding.gateway_id, target, - red, green, blue, 0, 0); - } - accepted = xy_ok && rgbcw_ok; + binding.pending_color_action = PendingColorAction::xy; + return scheduleDaliAction(binding); } else if (cluster_id == ColorControl::Id && (attribute_id == ColorControl::Attributes::CurrentHue::Id || attribute_id == ColorControl::Attributes::CurrentSaturation::Id)) { @@ -1536,25 +1673,148 @@ esp_err_t GatewayMatterBridge::handleAttributeUpdate( } else { binding.current_saturation = value.val.u8; } - int red = 0; - int green = 0; - int blue = 0; - HsvToRgb(binding.current_hue, binding.current_saturation, &red, &green, &blue); - bool xy_ok = true; - bool rgbcw_ok = true; - if (method != MatterColorMethod::rgbcw) { - uint16_t x = 0; - uint16_t y = 0; - RgbToXy(red, green, blue, &x, &y); - xy_ok = dali_domain_.setColourXY(binding.gateway_id, target, x, y); - } - if (method != MatterColorMethod::xy) { - rgbcw_ok = dali_domain_.setColourRGBCW(binding.gateway_id, target, - red, green, blue, 0, 0); - } - accepted = xy_ok && rgbcw_ok; + binding.pending_color_action = PendingColorAction::hueSaturation; + return scheduleDaliAction(binding); + } + return ESP_OK; +} + +esp_err_t GatewayMatterBridge::scheduleDaliAction(Binding& binding) { + if (command_task_ == nullptr) { + ESP_LOGW(kTag, "Matter DALI command task unavailable endpoint=%u", + binding.endpoint_id); + return ESP_ERR_INVALID_STATE; + } + xTaskNotifyGive(command_task_); + return ESP_OK; +} + +void GatewayMatterBridge::applyPendingDaliActions() { + struct Work { + uint16_t endpoint_id; + uint8_t gateway_id; + MatterTargetKind target_kind; + uint8_t target_address; + MatterEndpointType endpoint_type; + MatterColorMethod color_method; + MatterDaliAction action; + PendingColorAction color_action; + uint16_t color_temperature; + uint16_t x; + uint16_t y; + uint8_t hue; + uint8_t saturation; + }; + std::vector work; + { + std::lock_guard guard(bindings_mutex_); + work.reserve(bindings_.size()); + for (const auto& item : bindings_) { + Binding& binding = *item; + const MatterDaliAction action = ResolveMatterDaliAction( + binding.pending_on, binding.pending_level, binding.current_on, + binding.current_level); + const PendingColorAction color_action = binding.pending_color_action; + if (action.kind == MatterDaliActionKind::none && + color_action == PendingColorAction::none) { + continue; + } + work.push_back({ + binding.endpoint_id, + binding.gateway_id, + binding.target_kind, + binding.target_address, + binding.endpoint_type, + binding.color_method.value_or(MatterColorMethod::both), + action, + color_action, + binding.pending_color_temperature.value_or(0), + binding.current_x, + binding.current_y, + binding.current_hue, + binding.current_saturation, + }); + binding.pending_on.reset(); + binding.pending_level.reset(); + binding.pending_color_temperature.reset(); + binding.pending_color_action = PendingColorAction::none; + } + } + + for (const auto& item : work) { + bool accepted = true; + if (item.action.kind == MatterDaliActionKind::off) { + accepted = dali_domain_.off(item.gateway_id, item.target_address); + } else if (item.action.kind == MatterDaliActionKind::setLevel) { + accepted = dali_domain_.setBright(item.gateway_id, item.target_address, + item.action.level); + } + + if (item.color_action == PendingColorAction::temperature) { + bool native_ok = true; + bool rgbcw_ok = true; + const auto plan = ResolveMatterColorTemperatureWrites( + item.endpoint_type, item.color_method); + if (plan.native_temperature) { + native_ok = dali_domain_.setColTempRaw( + item.gateway_id, item.target_address, item.color_temperature); + } + if (plan.rgbcw) { + const int kelvin = item.color_temperature == 0 + ? 6500 + : 1000000 / item.color_temperature; + const double cool_ratio = std::clamp( + (kelvin - 2700.0) / (6500.0 - 2700.0), 0.0, 1.0); + rgbcw_ok = dali_domain_.setColourRGBCW( + item.gateway_id, item.target_address, 0, 0, 0, + static_cast(std::round(254.0 * cool_ratio)), + static_cast(std::round(254.0 * (1.0 - cool_ratio)))); + } + accepted = accepted && native_ok && rgbcw_ok; + } else if (item.color_action == PendingColorAction::xy) { + bool xy_ok = true; + bool rgbcw_ok = true; + if (item.color_method != MatterColorMethod::rgbcw) { + xy_ok = dali_domain_.setColourXY(item.gateway_id, item.target_address, + item.x, item.y); + } + if (item.color_method != MatterColorMethod::xy) { + int red = 0; + int green = 0; + int blue = 0; + XyToRgb(item.x, item.y, &red, &green, &blue); + rgbcw_ok = dali_domain_.setColourRGBCW( + item.gateway_id, item.target_address, red, green, blue, 0, 0); + } + accepted = accepted && xy_ok && rgbcw_ok; + } else if (item.color_action == PendingColorAction::hueSaturation) { + int red = 0; + int green = 0; + int blue = 0; + HsvToRgb(item.hue, item.saturation, &red, &green, &blue); + bool xy_ok = true; + bool rgbcw_ok = true; + if (item.color_method != MatterColorMethod::rgbcw) { + uint16_t x = 0; + uint16_t y = 0; + RgbToXy(red, green, blue, &x, &y); + xy_ok = dali_domain_.setColourXY(item.gateway_id, item.target_address, + x, y); + } + if (item.color_method != MatterColorMethod::xy) { + rgbcw_ok = dali_domain_.setColourRGBCW( + item.gateway_id, item.target_address, red, green, blue, 0, 0); + } + accepted = accepted && xy_ok && rgbcw_ok; + } + + if (!accepted) { + ESP_LOGW(kTag, + "deferred Matter command failed endpoint=%u gateway=%u target=%s:%u", + item.endpoint_id, item.gateway_id, + MatterTargetKindName(item.target_kind), item.target_address); + } } - return accepted ? ESP_OK : ESP_FAIL; } esp_err_t GatewayMatterBridge::Identify( @@ -1608,15 +1868,31 @@ void GatewayMatterBridge::ApplyStatusWork(intptr_t argument) { void GatewayMatterBridge::updateMatterLevel(Binding& binding, uint8_t level) { using namespace chip::app::Clusters; + const MatterLevelFeedback feedback = MapDaliLevelToMatter(level); + std::lock_guard guard(bindings_mutex_); + binding.current_on = feedback.on; + if (feedback.current_level.has_value()) { + binding.current_level = *feedback.current_level; + } binding.suppress_attribute_write = true; - auto on = esp_matter_bool(level > 0); - esp_matter::attribute::update(binding.endpoint_id, OnOff::Id, - OnOff::Attributes::OnOff::Id, &on); + auto on = esp_matter_bool(feedback.on); + const esp_err_t on_err = esp_matter::attribute::update( + binding.endpoint_id, OnOff::Id, OnOff::Attributes::OnOff::Id, &on); + if (on_err != ESP_OK) { + ESP_LOGW(kTag, "failed to mirror DALI on/off endpoint=%u: %s", + binding.endpoint_id, esp_err_to_name(on_err)); + } if (binding.matter_device_type != ESP_MATTER_ON_OFF_LIGHT_DEVICE_TYPE_ID) { - auto current_level = esp_matter_uint8(std::min(level, 254)); - esp_matter::attribute::update(binding.endpoint_id, LevelControl::Id, - LevelControl::Attributes::CurrentLevel::Id, - ¤t_level); + auto current_level = + esp_matter_nullable_uint8(nullable(binding.current_level)); + const esp_err_t level_err = esp_matter::attribute::update( + binding.endpoint_id, LevelControl::Id, + LevelControl::Attributes::CurrentLevel::Id, ¤t_level); + if (level_err != ESP_OK) { + ESP_LOGW(kTag, "failed to mirror DALI level endpoint=%u level=%u: %s", + binding.endpoint_id, binding.current_level, + esp_err_to_name(level_err)); + } } binding.suppress_attribute_write = false; } diff --git a/components/gateway_matter/src/gateway_matter_plan.cpp b/components/gateway_matter/src/gateway_matter_plan.cpp index 8a41298..29904f5 100644 --- a/components/gateway_matter/src/gateway_matter_plan.cpp +++ b/components/gateway_matter/src/gateway_matter_plan.cpp @@ -181,6 +181,42 @@ MatterEndpointPlan BuildMatterEndpointPlan( return plan; } +MatterLevelFeedback MapDaliLevelToMatter(uint8_t level) { + MatterLevelFeedback feedback; + feedback.on = level > 0 && level <= 254; + if (feedback.on) feedback.current_level = level; + return feedback; +} + +MatterDaliAction ResolveMatterDaliAction(std::optional requested_on, + std::optional requested_level, + bool settled_on, + uint8_t retained_level) { + if (requested_on.has_value() && !*requested_on) { + return {MatterDaliActionKind::off, retained_level}; + } + if (requested_on.has_value() && *requested_on) { + return {MatterDaliActionKind::setLevel, + requested_level.value_or(retained_level)}; + } + if (requested_level.has_value() && settled_on) { + return {MatterDaliActionKind::setLevel, *requested_level}; + } + return {}; +} + +MatterColorTemperatureWritePlan ResolveMatterColorTemperatureWrites( + MatterEndpointType endpoint_type, + std::optional color_method) { + if (endpoint_type == MatterEndpointType::colorTemperature) { + return {true, false}; + } + const MatterColorMethod method = + color_method.value_or(MatterColorMethod::both); + return {method != MatterColorMethod::rgbcw, + method != MatterColorMethod::xy}; +} + const char* MatterTargetKindName(MatterTargetKind kind) { switch (kind) { case MatterTargetKind::shortAddress: return "shortAddress"; diff --git a/components/gateway_matter/tests/gateway_matter_plan_test.cpp b/components/gateway_matter/tests/gateway_matter_plan_test.cpp index 080d015..f796399 100644 --- a/components/gateway_matter/tests/gateway_matter_plan_test.cpp +++ b/components/gateway_matter/tests/gateway_matter_plan_test.cpp @@ -69,4 +69,52 @@ int main() { assert(plan.dropped.front().allocation.target.address == 31); assert(plan.dropped.front().reason == "capacity"); } + { + const auto off = MapDaliLevelToMatter(0); + assert(!off.on); + assert(!off.current_level.has_value()); + const auto on = MapDaliLevelToMatter(137); + assert(on.on); + assert(on.current_level == 137); + const auto invalid = MapDaliLevelToMatter(255); + assert(!invalid.on); + assert(!invalid.current_level.has_value()); + } + { + // Matter's instantaneous Off effect visits level 1, switches OnOff off, + // then restores CurrentLevel. The settled action must still be only OFF. + const auto off = ResolveMatterDaliAction(false, 254, false, 254); + assert(off.kind == MatterDaliActionKind::off); + const auto on = ResolveMatterDaliAction(true, 254, true, 254); + assert(on.kind == MatterDaliActionKind::setLevel); + assert(on.level == 254); + const auto level_while_off = + ResolveMatterDaliAction(std::nullopt, 137, false, 137); + assert(level_while_off.kind == MatterDaliActionKind::none); + const auto level_while_on = + ResolveMatterDaliAction(std::nullopt, 137, true, 137); + assert(level_while_on.kind == MatterDaliActionKind::setLevel); + assert(level_while_on.level == 137); + } + { + const auto temperature_only = ResolveMatterColorTemperatureWrites( + MatterEndpointType::colorTemperature, std::nullopt); + assert(temperature_only.native_temperature); + assert(!temperature_only.rgbcw); + + const auto xy = ResolveMatterColorTemperatureWrites( + MatterEndpointType::color, MatterColorMethod::xy); + assert(xy.native_temperature); + assert(!xy.rgbcw); + + const auto rgbcw = ResolveMatterColorTemperatureWrites( + MatterEndpointType::color, MatterColorMethod::rgbcw); + assert(!rgbcw.native_temperature); + assert(rgbcw.rgbcw); + + const auto both = ResolveMatterColorTemperatureWrites( + MatterEndpointType::color, MatterColorMethod::both); + assert(both.native_temperature); + assert(both.rgbcw); + } } diff --git a/components/gateway_network/include/gateway_network.hpp b/components/gateway_network/include/gateway_network.hpp index f4d0abe..9304bf6 100644 --- a/components/gateway_network/include/gateway_network.hpp +++ b/components/gateway_network/include/gateway_network.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -92,6 +93,10 @@ class GatewayNetworkService { // Enables the opt-in Wi-Fi provisioning path used by physical adapters such // as the Matter commissioning button. Ethernet remains the preferred route. esp_err_t startWifiProvisioning(); + // Re-announces IPv6 after the Matter server has registered its ESP event + // handler. The network service starts first, so the initial link event may + // otherwise predate CHIP startup. + void refreshMatterIpv6(); // Physical adapter for the Part 103 IDENTIFY DEVICE action. The DALI core // remains hardware-independent and invokes this only through the app wiring. void identify(); @@ -107,6 +112,7 @@ class GatewayNetworkService { static void TcpControlTaskEntry(void* arg); static void BootButtonTaskEntry(void* arg); static void IdentifyTaskEntry(void* arg); + static void MatterIpv6RefreshTaskEntry(void* arg); static esp_err_t HandleInfoGet(httpd_req_t* req); static esp_err_t HandleCommandGet(httpd_req_t* req); static esp_err_t HandleCommandPost(httpd_req_t* req); @@ -144,6 +150,10 @@ class GatewayNetworkService { void tcpControlTaskLoop(); void bootButtonTaskLoop(); void identifyTaskLoop(); + void matterIpv6RefreshTaskLoop(); + void scheduleMatterIpv6Refresh(); + esp_err_t promoteMatterIpv6LinkLocal(esp_netif_t* netif, + const char* interface_name); void handleNetworkControlBytes(const uint8_t* data, size_t len); std::optional handleJsonControlFrame(const uint8_t* data, size_t len); bool enqueueControlFrameForTargets(const std::vector& frame); @@ -160,6 +170,7 @@ class GatewayNetworkService { void handleEspNowReceive(const esp_now_recv_info_t* info, const uint8_t* data, int data_len); void handleSetupUartFrame(int setup_id, const std::vector& frame); void handleDaliRawFrame(const DaliRawFrame& frame); + esp_err_t ensureMatterIpv6(esp_netif_t* netif, const char* interface_name); std::string deviceInfoJson() const; std::string deviceInfoDoubleEncodedJson() const; std::string gatewaySnapshotJson(); @@ -196,6 +207,10 @@ class GatewayNetworkService { TaskHandle_t identify_task_handle_{nullptr}; TaskHandle_t udp_task_handle_{nullptr}; TaskHandle_t tcp_control_task_handle_{nullptr}; + std::atomic_bool matter_event_handlers_ready_{false}; + std::atomic_bool matter_ipv6_refresh_running_{false}; + std::atomic_bool ethernet_link_up_{false}; + std::atomic_bool wifi_station_connected_{false}; int udp_socket_{-1}; int tcp_control_socket_{-1}; int tcp_control_client_socket_{-1}; diff --git a/components/gateway_network/src/gateway_network.cpp b/components/gateway_network/src/gateway_network.cpp index 8ec4d95..ea38c9e 100644 --- a/components/gateway_network/src/gateway_network.cpp +++ b/components/gateway_network/src/gateway_network.cpp @@ -31,6 +31,11 @@ #include #include +#if CONFIG_GATEWAY_MATTER_SUPPORTED && \ + (!CONFIG_LWIP_IPV6 || !CONFIG_LWIP_IPV6_AUTOCONFIG) +#error "Matter gateway builds require IPv6 and IPv6 autoconfiguration" +#endif + namespace gateway { namespace { @@ -40,6 +45,11 @@ constexpr const char* kSetupApSsid = "LAMMIN_Gateway"; constexpr size_t kUdpBufferSize = 1024; constexpr size_t kTcpControlBufferSize = 512; constexpr uint8_t kEspNowBroadcastMac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; +#ifdef CONFIG_GATEWAY_MATTER_SUPPORTED +constexpr TickType_t kMatterIpv6RefreshInterval = pdMS_TO_TICKS(500); +constexpr int kMatterIpv6RefreshAttempts = 150; +constexpr int kMatterIpv6PromotionChecks = 3; +#endif GatewayNetworkService* s_espnow_service = nullptr; @@ -491,6 +501,168 @@ esp_err_t GatewayNetworkService::startWifiProvisioning() { return startSmartconfig(); } +void GatewayNetworkService::refreshMatterIpv6() { + matter_event_handlers_ready_.store(true, std::memory_order_release); + scheduleMatterIpv6Refresh(); +} + +void GatewayNetworkService::scheduleMatterIpv6Refresh() { +#ifndef CONFIG_GATEWAY_MATTER_SUPPORTED + return; +#else + if (!matter_event_handlers_ready_.load(std::memory_order_acquire) || + matter_ipv6_refresh_running_.exchange(true, std::memory_order_acq_rel)) { + return; + } + const BaseType_t created = + xTaskCreate(MatterIpv6RefreshTaskEntry, "matter_ipv6", 3072, this, 3, + nullptr); + if (created != pdPASS) { + matter_ipv6_refresh_running_.store(false, std::memory_order_release); + ESP_LOGE(kTag, "failed to start Matter IPv6 refresh task"); + } +#endif +} + +void GatewayNetworkService::MatterIpv6RefreshTaskEntry(void* arg) { + static_cast(arg)->matterIpv6RefreshTaskLoop(); +} + +void GatewayNetworkService::matterIpv6RefreshTaskLoop() { +#ifdef CONFIG_GATEWAY_MATTER_SUPPORTED + bool ethernet_requested = false; + bool wifi_requested = false; + bool ethernet_promoted = false; + bool wifi_promoted = false; + int ethernet_pending_checks = 0; + int wifi_pending_checks = 0; + for (int attempt = 0; attempt < kMatterIpv6RefreshAttempts; ++attempt) { + const auto announce = [this](esp_netif_t* netif, + const char* interface_name, + bool* requested, + bool* promoted, + int* pending_checks) { + if (!*requested) { + const esp_err_t err = ensureMatterIpv6(netif, interface_name); + if (err != ESP_OK) return false; + *requested = true; + *pending_checks = 0; + ESP_LOGI(kTag, "requested %s Matter IPv6 link-local address", + interface_name); + } + + esp_ip6_addr_t address{}; + if (esp_netif_get_ip6_linklocal(netif, &address) != ESP_OK) { + ++*pending_checks; + if (*pending_checks >= kMatterIpv6PromotionChecks && !*promoted) { + const esp_err_t err = + promoteMatterIpv6LinkLocal(netif, interface_name); + if (err == ESP_OK) *promoted = true; + } + return false; + } + ip_event_got_ip6_t event{}; + event.esp_netif = netif; + event.ip_index = 0; + event.ip6_info.ip = address; + const esp_err_t err = esp_event_post( + IP_EVENT, IP_EVENT_GOT_IP6, &event, sizeof(event), + kMatterIpv6RefreshInterval); + if (err != ESP_OK) { + ESP_LOGW(kTag, "failed to announce %s Matter IPv6 address: %s", + interface_name, esp_err_to_name(err)); + return false; + } + ESP_LOGI(kTag, "announced %s Matter IPv6 " IPV6STR, + interface_name, IPV62STR(address)); + return true; + }; + + const bool ethernet_connected = + ethernet_link_up_.load(std::memory_order_acquire); + const bool wifi_connected = + wifi_station_connected_.load(std::memory_order_acquire); + bool ethernet_ready = !ethernet_connected; + bool wifi_ready = !wifi_connected; + if (ethernet_connected) { + ethernet_ready = announce(eth_netif_, "Ethernet", ðernet_requested, + ðernet_promoted, + ðernet_pending_checks); + } else { + ethernet_requested = false; + ethernet_promoted = false; + ethernet_pending_checks = 0; + } + if (wifi_connected) { + wifi_ready = announce(wifi_sta_netif_, "Wi-Fi", &wifi_requested, + &wifi_promoted, &wifi_pending_checks); + } else { + wifi_requested = false; + wifi_promoted = false; + wifi_pending_checks = 0; + } + if ((ethernet_connected || wifi_connected) && ethernet_ready && wifi_ready) { + break; + } + vTaskDelay(kMatterIpv6RefreshInterval); + } +#endif + matter_ipv6_refresh_running_.store(false, std::memory_order_release); + vTaskDelete(nullptr); +} + +esp_err_t GatewayNetworkService::promoteMatterIpv6LinkLocal( + esp_netif_t* netif, const char* interface_name) { + uint8_t mac[6] = {}; + esp_err_t err = esp_netif_get_mac(netif, mac); + if (err != ESP_OK) { + ESP_LOGW(kTag, "failed to read %s MAC for Matter IPv6: %s", + interface_name, esp_err_to_name(err)); + return err; + } + + char address_text[48] = {}; + std::snprintf(address_text, sizeof(address_text), + "fe80::%02x%02x:%02xff:fe%02x:%02x%02x", + static_cast(mac[0] ^ 0x02), + static_cast(mac[1]), + static_cast(mac[2]), + static_cast(mac[3]), + static_cast(mac[4]), + static_cast(mac[5])); + esp_ip6_addr_t address{}; + err = esp_netif_str_to_ip6(address_text, &address); + if (err != ESP_OK) { + ESP_LOGW(kTag, "failed to parse %s Matter IPv6 address %s: %s", + interface_name, address_text, esp_err_to_name(err)); + return err; + } + + err = esp_netif_add_ip6_address(netif, address, true); + if (err != ESP_OK) { + ESP_LOGW(kTag, "failed to promote %s Matter IPv6 address %s: %s", + interface_name, address_text, esp_err_to_name(err)); + return err; + } + ESP_LOGW(kTag, + "%s Matter IPv6 DAD did not settle; promoted MAC-derived address %s", + interface_name, address_text); + return ESP_OK; +} + +esp_err_t GatewayNetworkService::ensureMatterIpv6( + esp_netif_t* netif, const char* interface_name) { + if (netif == nullptr || !esp_netif_is_netif_up(netif)) { + return ESP_ERR_INVALID_STATE; + } + const esp_err_t err = esp_netif_create_ip6_linklocal(netif); + if (err != ESP_OK) { + ESP_LOGW(kTag, "failed to create %s Matter IPv6 address: %s", + interface_name, esp_err_to_name(err)); + } + return err; +} + void GatewayNetworkService::identify() { if (config_.status_led_gpio < 0) { ESP_LOGI(kTag, "Part 103 identity requested; no status LED is configured"); @@ -556,6 +728,18 @@ esp_err_t GatewayNetworkService::startEthernet() { stopEthernet(); return err; } + err = esp_event_handler_register(IP_EVENT, IP_EVENT_GOT_IP6, + &GatewayNetworkService::HandleEthernetEvent, this); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(kTag, "failed to register Ethernet IPv6 event handler: %s", + esp_err_to_name(err)); + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_event_handler_unregister( + IP_EVENT, IP_EVENT_ETH_GOT_IP, &GatewayNetworkService::HandleEthernetEvent)); + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_event_handler_unregister( + ETH_EVENT, ESP_EVENT_ANY_ID, &GatewayNetworkService::HandleEthernetEvent)); + stopEthernet(); + return err; + } ethernet_event_handlers_registered_ = true; } @@ -709,6 +893,8 @@ esp_err_t GatewayNetworkService::probeEthernetStartup() { void GatewayNetworkService::stopEthernet() { if (ethernet_event_handlers_registered_) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_event_handler_unregister( + IP_EVENT, IP_EVENT_GOT_IP6, &GatewayNetworkService::HandleEthernetEvent)); ESP_ERROR_CHECK_WITHOUT_ABORT(esp_event_handler_unregister( IP_EVENT, IP_EVENT_ETH_GOT_IP, &GatewayNetworkService::HandleEthernetEvent)); ESP_ERROR_CHECK_WITHOUT_ABORT(esp_event_handler_unregister( @@ -802,6 +988,17 @@ esp_err_t GatewayNetworkService::startWifi() { ESP_LOGE(kTag, "failed to register IP event handler: %s", esp_err_to_name(err)); return err; } + err = esp_event_handler_register(IP_EVENT, IP_EVENT_GOT_IP6, + &GatewayNetworkService::HandleWifiEvent, this); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(kTag, "failed to register Wi-Fi IPv6 event handler: %s", + esp_err_to_name(err)); + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_event_handler_unregister( + IP_EVENT, IP_EVENT_STA_GOT_IP, &GatewayNetworkService::HandleWifiEvent)); + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_event_handler_unregister( + WIFI_EVENT, ESP_EVENT_ANY_ID, &GatewayNetworkService::HandleWifiEvent)); + return err; + } wifi_event_handlers_registered_ = true; } @@ -1279,6 +1476,7 @@ void GatewayNetworkService::handleEthernetEvent(esp_event_base_t event_base, int } if (event_id == ETHERNET_EVENT_CONNECTED) { + ethernet_link_up_.store(true, std::memory_order_release); uint8_t mac[6] = {}; if (handle != nullptr && esp_eth_ioctl(handle, ETH_CMD_G_MAC_ADDR, mac) == ESP_OK) { const std::string mac_hex = MacToHex(mac); @@ -1289,9 +1487,16 @@ void GatewayNetworkService::handleEthernetEvent(esp_event_base_t event_base, int } else { ESP_LOGI(kTag, "Ethernet link up"); } + ensureMatterIpv6(eth_netif_, "Ethernet"); + scheduleMatterIpv6Refresh(); return; } + if (event_id == ETHERNET_EVENT_DISCONNECTED || + event_id == ETHERNET_EVENT_STOP) { + ethernet_link_up_.store(false, std::memory_order_release); + } + if (event_id == ETHERNET_EVENT_DISCONNECTED) { runtime_.clearEthernetIp(); selectPreferredDefaultNetwork(); @@ -1325,6 +1530,15 @@ void GatewayNetworkService::handleEthernetEvent(esp_event_base_t event_base, int runtime_.setEthernetInfo(std::move(info)); selectPreferredDefaultNetwork(); ESP_LOGI(kTag, "Ethernet got IP %s", ip); + return; + } + + if (event_base == IP_EVENT && event_id == IP_EVENT_GOT_IP6 && + event_data != nullptr) { + auto* event = static_cast(event_data); + if (event->esp_netif != eth_netif_) return; + ESP_LOGI(kTag, "Ethernet got Matter IPv6 " IPV6STR, + IPV62STR(event->ip6_info.ip)); } } @@ -1378,7 +1592,15 @@ void GatewayNetworkService::handleWifiEvent(esp_event_base_t event_base, int32_t return; } + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_CONNECTED) { + wifi_station_connected_.store(true, std::memory_order_release); + ensureMatterIpv6(wifi_sta_netif_, "Wi-Fi"); + scheduleMatterIpv6Refresh(); + return; + } + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + wifi_station_connected_.store(false, std::memory_order_release); auto info = runtime_.deviceInfo(); if (info.wlan.has_value()) { const bool has_credentials = !info.wlan->ssid.empty(); @@ -1405,6 +1627,15 @@ void GatewayNetworkService::handleWifiEvent(esp_event_base_t event_base, int32_t runtime_.setWirelessInfo(std::move(wireless)); selectPreferredDefaultNetwork(); ESP_LOGI(kTag, "Wi-Fi got IP %s", ip); + return; + } + + if (event_base == IP_EVENT && event_id == IP_EVENT_GOT_IP6 && + event_data != nullptr) { + auto* event = static_cast(event_data); + if (event->esp_netif != wifi_sta_netif_) return; + ESP_LOGI(kTag, "Wi-Fi got Matter IPv6 " IPV6STR, + IPV62STR(event->ip6_info.ip)); } }