#include "gateway_bridge.hpp" #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) #include "bacnet_bridge.hpp" #include "gateway_bacnet.hpp" #endif #include "bridge.hpp" #include "bridge_model.hpp" #include "bridge_provisioning.hpp" #include "dali_comm.hpp" #include "dali_domain.hpp" #include "gateway_cloud.hpp" #include "gateway_provisioning.hpp" #include "modbus_bridge.hpp" #include "cJSON.h" #include "esp_log.h" #include "freertos/semphr.h" #include "lwip/inet.h" #include "lwip/sockets.h" #include #include #include #include #include #include #include #include namespace gateway { namespace { constexpr const char* kTag = "gateway_bridge"; constexpr int kDefaultModbusPort = 1502; constexpr size_t kModbusMaxPduBytes = 252; class LockGuard { public: explicit LockGuard(SemaphoreHandle_t lock) : lock_(lock) { if (lock_ != nullptr) { xSemaphoreTakeRecursive(lock_, portMAX_DELAY); } } ~LockGuard() { if (lock_ != nullptr) { xSemaphoreGiveRecursive(lock_); } } private: SemaphoreHandle_t lock_; }; std::string PrintJson(cJSON* node) { if (node == nullptr) { return "{}"; } char* rendered = cJSON_PrintUnformatted(node); if (rendered == nullptr) { return "{}"; } std::string out(rendered); cJSON_free(rendered); return out; } GatewayBridgeHttpResponse JsonOk(cJSON* node) { const std::string body = PrintJson(node); cJSON_Delete(node); return GatewayBridgeHttpResponse{ESP_OK, body}; } GatewayBridgeHttpResponse ErrorResponse(esp_err_t err, const char* message) { cJSON* root = cJSON_CreateObject(); if (root != nullptr) { cJSON_AddBoolToObject(root, "ok", false); cJSON_AddStringToObject(root, "error", message == nullptr ? "error" : message); cJSON_AddNumberToObject(root, "espErr", static_cast(err)); } const std::string body = PrintJson(root); cJSON_Delete(root); return GatewayBridgeHttpResponse{err, body}; } const char* JsonString(const cJSON* parent, const char* name) { const cJSON* item = cJSON_GetObjectItemCaseSensitive(parent, name); return cJSON_IsString(item) && item->valuestring != nullptr ? item->valuestring : nullptr; } std::optional JsonInt(const cJSON* parent, const char* name) { const cJSON* item = cJSON_GetObjectItemCaseSensitive(parent, name); if (!cJSON_IsNumber(item)) { return std::nullopt; } return item->valueint; } std::optional JsonGatewayId(const cJSON* root) { if (root == nullptr) { return std::nullopt; } const auto gateway = JsonInt(root, "gw").value_or(JsonInt(root, "gatewayId").value_or(-1)); if (gateway < 0 || gateway > 255) { return std::nullopt; } return static_cast(gateway); } std::string QueryValue(std::string_view query, std::string_view key) { if (query.empty() || key.empty()) { return {}; } size_t start = 0; while (start < query.size()) { const size_t end = query.find('&', start); const size_t segment_end = end == std::string_view::npos ? query.size() : end; const std::string_view segment = query.substr(start, segment_end - start); const size_t equals = segment.find('='); if (equals != std::string_view::npos && segment.substr(0, equals) == key) { return std::string(segment.substr(equals + 1)); } if (end == std::string_view::npos) { break; } start = end + 1; } return {}; } std::optional ParseInt(std::string_view raw) { if (raw.empty()) { return std::nullopt; } std::string text(raw); char* end = nullptr; const long parsed = std::strtol(text.c_str(), &end, 10); if (end == text.c_str() || *end != '\0') { return std::nullopt; } return static_cast(parsed); } std::optional QueryInt(std::string_view query, std::string_view primary, std::string_view fallback = {}) { auto value = ParseInt(QueryValue(query, primary)); if (value.has_value() || fallback.empty()) { return value; } return ParseInt(QueryValue(query, fallback)); } std::optional JsonIntAny(const cJSON* parent, const char* primary, const char* fallback) { auto value = JsonInt(parent, primary); if (value.has_value() || fallback == nullptr) { return value; } return JsonInt(parent, fallback); } bool ValidDaliAddress(int address) { return address >= 0 && address <= 127; } cJSON* IntArrayToCjson(const std::vector& values) { cJSON* array = cJSON_CreateArray(); if (array == nullptr) { return nullptr; } for (const int value : values) { cJSON_AddItemToArray(array, cJSON_CreateNumber(value)); } return array; } cJSON* NumberArrayToCjson(const std::vector& values) { cJSON* array = cJSON_CreateArray(); if (array == nullptr) { return nullptr; } for (const double value : values) { cJSON_AddItemToArray(array, cJSON_CreateNumber(value)); } return array; } cJSON* SnapshotToCjson(const DaliDomainSnapshot& snapshot) { cJSON* root = cJSON_CreateObject(); if (root == nullptr) { return nullptr; } cJSON_AddNumberToObject(root, "gatewayId", snapshot.gateway_id); cJSON_AddNumberToObject(root, "address", snapshot.address); cJSON_AddStringToObject(root, "kind", snapshot.kind.c_str()); for (const auto& entry : snapshot.bools) { cJSON_AddBoolToObject(root, entry.first.c_str(), entry.second); } for (const auto& entry : snapshot.ints) { cJSON_AddNumberToObject(root, entry.first.c_str(), entry.second); } for (const auto& entry : snapshot.numbers) { cJSON_AddNumberToObject(root, entry.first.c_str(), entry.second); } for (const auto& entry : snapshot.int_arrays) { cJSON_AddItemToObject(root, entry.first.c_str(), IntArrayToCjson(entry.second)); } for (const auto& entry : snapshot.number_arrays) { cJSON_AddItemToObject(root, entry.first.c_str(), NumberArrayToCjson(entry.second)); } return root; } GatewayBridgeHttpResponse SnapshotResponse(const std::optional& snapshot, const char* missing_message) { if (!snapshot.has_value()) { return ErrorResponse(ESP_ERR_NOT_FOUND, missing_message); } return JsonOk(SnapshotToCjson(snapshot.value())); } GatewayBridgeHttpResponse StoredSnapshotResponse( const std::optional& snapshot, uint8_t gateway_id, int address, const char* kind) { if (snapshot.has_value()) { return JsonOk(SnapshotToCjson(snapshot.value())); } cJSON* root = cJSON_CreateObject(); cJSON_AddBoolToObject(root, "ok", true); cJSON_AddBoolToObject(root, "stored", true); cJSON_AddBoolToObject(root, "reportAvailable", false); cJSON_AddNumberToObject(root, "gatewayId", gateway_id); cJSON_AddNumberToObject(root, "address", address); cJSON_AddStringToObject(root, "kind", kind == nullptr ? "dt8_snapshot" : kind); return JsonOk(root); } DaliDt8SceneColorMode JsonColorMode(const cJSON* root) { const cJSON* item = cJSON_GetObjectItemCaseSensitive(root, "colorMode"); if (item == nullptr) { item = cJSON_GetObjectItemCaseSensitive(root, "color_mode"); } if (cJSON_IsNumber(item)) { if (item->valueint == 1) { return DaliDt8SceneColorMode::kColorTemperature; } if (item->valueint == 2) { return DaliDt8SceneColorMode::kRgb; } return DaliDt8SceneColorMode::kDisabled; } if (!cJSON_IsString(item) || item->valuestring == nullptr) { return DaliDt8SceneColorMode::kDisabled; } const std::string_view mode(item->valuestring); if (mode == "colorTemperature" || mode == "color_temperature" || mode == "ct") { return DaliDt8SceneColorMode::kColorTemperature; } if (mode == "rgb") { return DaliDt8SceneColorMode::kRgb; } return DaliDt8SceneColorMode::kDisabled; } GatewayBridgeHttpResponse StoreDt8SceneSnapshot(DaliDomainService& domain, uint8_t gateway_id, std::string_view body) { cJSON* root = cJSON_ParseWithLength(body.data(), body.size()); if (root == nullptr) { return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid DT8 scene snapshot JSON"); } const auto address = JsonIntAny(root, "addr", "address"); const auto scene = JsonInt(root, "scene"); const auto brightness = JsonInt(root, "brightness"); const auto color_mode = JsonColorMode(root); const int color_temperature = JsonIntAny(root, "colorTemperature", "color_temperature").value_or(0); const int red = JsonIntAny(root, "red", "r").value_or(0); const int green = JsonIntAny(root, "green", "g").value_or(0); const int blue = JsonIntAny(root, "blue", "b").value_or(0); cJSON_Delete(root); if (!address.has_value() || !scene.has_value() || !brightness.has_value() || !ValidDaliAddress(address.value()) || scene.value() < 0 || scene.value() > 15) { return ErrorResponse(ESP_ERR_INVALID_ARG, "addr, scene, and brightness are required"); } if (!domain.storeDt8SceneSnapshot(gateway_id, address.value(), scene.value(), brightness.value(), color_mode, color_temperature, red, green, blue)) { return ErrorResponse(ESP_FAIL, "failed to store DT8 scene snapshot"); } return StoredSnapshotResponse(domain.dt8SceneColorReport(gateway_id, address.value(), scene.value()), gateway_id, address.value(), "dt8_scene"); } GatewayBridgeHttpResponse StoreDt8LevelSnapshot(DaliDomainService& domain, uint8_t gateway_id, std::string_view body, bool power_on) { cJSON* root = cJSON_ParseWithLength(body.data(), body.size()); if (root == nullptr) { return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid DT8 level snapshot JSON"); } const auto address = JsonIntAny(root, "addr", "address"); const auto level = JsonInt(root, "level"); cJSON_Delete(root); if (!address.has_value() || !level.has_value() || !ValidDaliAddress(address.value())) { return ErrorResponse(ESP_ERR_INVALID_ARG, "addr and level are required"); } const bool stored = power_on ? domain.storeDt8PowerOnLevelSnapshot(gateway_id, address.value(), level.value()) : domain.storeDt8SystemFailureLevelSnapshot(gateway_id, address.value(), level.value()); if (!stored) { return ErrorResponse(ESP_FAIL, power_on ? "failed to store DT8 power-on snapshot" : "failed to store DT8 system-failure snapshot"); } return StoredSnapshotResponse(power_on ? domain.dt8PowerOnLevelColorReport(gateway_id, address.value()) : domain.dt8SystemFailureLevelColorReport( gateway_id, address.value()), gateway_id, address.value(), power_on ? "dt8_power_on" : "dt8_system_failure"); } DaliValue FromCjson(const cJSON* item) { if (item == nullptr || cJSON_IsNull(item)) { return DaliValue(); } if (cJSON_IsBool(item)) { return DaliValue(cJSON_IsTrue(item)); } if (cJSON_IsNumber(item)) { const double value = item->valuedouble; if (value == static_cast(item->valueint)) { return DaliValue(item->valueint); } return DaliValue(value); } if (cJSON_IsString(item) && item->valuestring != nullptr) { return DaliValue(std::string(item->valuestring)); } if (cJSON_IsArray(item)) { DaliValue::Array out; for (const cJSON* child = item->child; child != nullptr; child = child->next) { out.push_back(FromCjson(child)); } return DaliValue(std::move(out)); } if (cJSON_IsObject(item)) { DaliValue::Object out; for (const cJSON* child = item->child; child != nullptr; child = child->next) { if (child->string != nullptr) { out[child->string] = FromCjson(child); } } return DaliValue(std::move(out)); } return DaliValue(); } cJSON* ToCjson(const DaliValue& value) { if (value.isNull()) { return cJSON_CreateNull(); } if (value.isBool()) { return cJSON_CreateBool(value.asBool().value_or(false)); } if (value.isInt()) { return cJSON_CreateNumber(value.asInt().value_or(0)); } if (value.isDouble()) { return cJSON_CreateNumber(value.asDouble().value_or(0.0)); } if (value.isString()) { return cJSON_CreateString(value.asString().value_or("").c_str()); } if (const auto* array = value.asArray()) { cJSON* out = cJSON_CreateArray(); for (const auto& item : *array) { cJSON_AddItemToArray(out, ToCjson(item)); } return out; } if (const auto* object = value.asObject()) { cJSON* out = cJSON_CreateObject(); for (const auto& entry : *object) { cJSON_AddItemToObject(out, entry.first.c_str(), ToCjson(entry.second)); } return out; } return cJSON_CreateNull(); } std::string BridgeRuntimeConfigToJson(const BridgeRuntimeConfig& config) { cJSON* root = ToCjson(DaliValue(config.toJson())); const std::string body = PrintJson(root); cJSON_Delete(root); return body; } std::optional BridgeRuntimeConfigFromJson(std::string_view json) { cJSON* root = cJSON_ParseWithLength(json.data(), json.size()); if (root == nullptr) { return std::nullopt; } const DaliValue value = FromCjson(root); cJSON_Delete(root); const auto* object = value.asObject(); if (object == nullptr) { return std::nullopt; } return BridgeRuntimeConfig::fromJson(*object); } GatewayCloudConfig GatewayCloudConfigFromJson(cJSON* root) { GatewayCloudConfig config; if (const char* value = JsonString(root, "brokerURI")) { config.brokerURI = value; } if (const char* value = JsonString(root, "deviceID")) { config.deviceID = value; } if (const char* value = JsonString(root, "username")) { config.username = value; } if (const char* value = JsonString(root, "password")) { config.password = value; } if (const char* value = JsonString(root, "topicPrefix")) { config.topicPrefix = value; } if (const auto qos = JsonInt(root, "qos")) { config.qos = qos.value(); } return config; } cJSON* GatewayCloudConfigToCjson(const GatewayCloudConfig& config) { cJSON* root = cJSON_CreateObject(); if (root == nullptr) { return nullptr; } cJSON_AddStringToObject(root, "brokerURI", config.brokerURI.c_str()); cJSON_AddStringToObject(root, "deviceID", config.deviceID.c_str()); cJSON_AddStringToObject(root, "username", config.username.c_str()); cJSON_AddStringToObject(root, "password", config.password.c_str()); cJSON_AddStringToObject(root, "topicPrefix", config.topicPrefix.c_str()); cJSON_AddNumberToObject(root, "qos", config.qos); return root; } DaliBridgeRequest BridgeRequestFromJson(cJSON* root) { DaliBridgeRequest request; if (const char* seq = JsonString(root, "seq")) { request.sequence = seq; } if (const char* model = JsonString(root, "model")) { request.modelID = model; } if (const char* op = JsonString(root, "op")) { request.operation = bridgeOperationFromString(op); } if (const auto addr = JsonInt(root, "addr")) { request.rawAddress = addr.value(); } if (const auto cmd = JsonInt(root, "cmd")) { request.rawCommand = cmd.value(); } if (const auto short_address = JsonInt(root, "shortAddress")) { request.shortAddress = short_address.value(); } if (const cJSON* value = cJSON_GetObjectItemCaseSensitive(root, "value")) { request.value = FromCjson(value); } if (const cJSON* meta = cJSON_GetObjectItemCaseSensitive(root, "meta")) { const auto meta_value = FromCjson(meta); if (const auto* object = meta_value.asObject()) { request.metadata = *object; } } return request; } cJSON* BridgeResultToCjson(const DaliBridgeResult& result) { return ToCjson(DaliValue(result.toJson())); } uint16_t ReadBe16(const uint8_t* data) { return static_cast((static_cast(data[0]) << 8) | data[1]); } void WriteBe16(uint8_t* data, uint16_t value) { data[0] = static_cast((value >> 8) & 0xFF); data[1] = static_cast(value & 0xFF); } bool RecvAll(int sock, uint8_t* buffer, size_t len) { size_t received = 0; while (received < len) { const int ret = recv(sock, buffer + received, len - received, 0); if (ret <= 0) { return false; } received += static_cast(ret); } return true; } bool SendAll(int sock, const uint8_t* buffer, size_t len) { size_t sent = 0; while (sent < len) { const int ret = send(sock, buffer + sent, len - sent, 0); if (ret <= 0) { return false; } sent += static_cast(ret); } return true; } bool SendModbusFrame(int sock, const uint8_t* mbap, const std::vector& pdu) { std::vector frame(7 + pdu.size()); std::memcpy(frame.data(), mbap, 7); WriteBe16(&frame[4], static_cast(pdu.size() + 1)); std::memcpy(frame.data() + 7, pdu.data(), pdu.size()); return SendAll(sock, frame.data(), frame.size()); } bool SendModbusException(int sock, const uint8_t* mbap, uint8_t function_code, uint8_t exception_code) { const std::vector pdu{static_cast(function_code | 0x80), exception_code}; return SendModbusFrame(sock, mbap, pdu); } int HoldingRegisterFromWireAddress(uint16_t zero_based_address) { return 40001 + static_cast(zero_based_address); } } // namespace struct GatewayBridgeService::ChannelRuntime { explicit ChannelRuntime(DaliDomainService& domain, DaliChannelInfo channel, GatewayBridgeServiceConfig service_config) : domain(domain), channel(std::move(channel)), service_config(service_config), lock(xSemaphoreCreateRecursiveMutex()) {} ~ChannelRuntime() { if (cloud != nullptr) { cloud->stop(); } if (lock != nullptr) { vSemaphoreDelete(lock); lock = nullptr; } } DaliDomainService& domain; DaliChannelInfo channel; GatewayBridgeServiceConfig service_config; SemaphoreHandle_t lock{nullptr}; std::unique_ptr comm; std::unique_ptr engine; std::unique_ptr modbus; #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) std::unique_ptr bacnet; #endif std::unique_ptr cloud; BridgeRuntimeConfig bridge_config; std::optional cloud_config; bool bridge_config_loaded{false}; bool cloud_config_loaded{false}; bool cloud_started{false}; bool modbus_started{false}; bool bacnet_started{false}; TaskHandle_t modbus_task_handle{nullptr}; static void ModbusTaskEntry(void* arg) { static_cast(arg)->modbusTaskLoop(); } std::string bridgeNamespace() const { return "dali_bridge_" + std::to_string(channel.gateway_id); } std::string cloudNamespace() const { return "dali_cloud_" + std::to_string(channel.gateway_id); } esp_err_t start() { comm = std::make_unique( [this](const uint8_t* data, size_t len) { return domain.writeBridgeFrame(channel.gateway_id, data, len); }, nullptr, [this](const uint8_t* data, size_t len) { return domain.transactBridgeFrame(channel.gateway_id, data, len); }, [](uint32_t ms) { vTaskDelay(pdMS_TO_TICKS(ms)); }); BridgeProvisioningStore bridge_store(bridgeNamespace()); bridge_config_loaded = bridge_store.load(&bridge_config) == ESP_OK; applyBridgeConfigLocked(); GatewayProvisioningStore cloud_store(cloudNamespace()); GatewayCloudConfig loaded_cloud; if (cloud_store.load(&loaded_cloud) == ESP_OK) { cloud_config = loaded_cloud; cloud_config_loaded = true; } applyCloudModelsLocked(); if (service_config.cloud_enabled && service_config.cloud_startup_enabled) { startCloudLocked(); } return ESP_OK; } void applyBridgeConfigLocked() { engine = std::make_unique(*comm); for (const auto& model : bridge_config.models) { engine->upsertModel(model); } modbus = std::make_unique(*engine); if (bridge_config.modbus.has_value()) { modbus->setConfig(bridge_config.modbus.value()); } #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) if (service_config.bacnet_enabled) { bacnet = std::make_unique(*engine); if (bridge_config.bacnet.has_value()) { bacnet->setConfig(bridge_config.bacnet.value()); } } else { bacnet.reset(); } #endif applyCloudModelsLocked(); bacnet_started = false; } void applyCloudModelsLocked() { if (cloud_started && cloud != nullptr) { cloud->stop(); cloud_started = false; } cloud = std::make_unique(*comm); for (const auto& model : bridge_config.models) { cloud->bridge().upsertModel(model); } } esp_err_t saveBridgeConfig(std::string_view json) { auto parsed = BridgeRuntimeConfigFromJson(json); if (!parsed.has_value()) { return ESP_ERR_INVALID_ARG; } BridgeProvisioningStore store(bridgeNamespace()); const esp_err_t err = store.save(parsed.value()); if (err != ESP_OK) { return err; } LockGuard guard(lock); bridge_config = parsed.value(); bridge_config_loaded = true; applyBridgeConfigLocked(); return ESP_OK; } esp_err_t clearBridgeConfig() { BridgeProvisioningStore store(bridgeNamespace()); const esp_err_t err = store.clear(); if (err != ESP_OK) { return err; } LockGuard guard(lock); bridge_config = BridgeRuntimeConfig{}; bridge_config_loaded = false; applyBridgeConfigLocked(); return ESP_OK; } esp_err_t saveCloudConfig(std::string_view json) { cJSON* root = cJSON_ParseWithLength(json.data(), json.size()); if (root == nullptr) { return ESP_ERR_INVALID_ARG; } const GatewayCloudConfig parsed = GatewayCloudConfigFromJson(root); cJSON_Delete(root); GatewayProvisioningStore store(cloudNamespace()); const esp_err_t err = store.save(parsed); if (err != ESP_OK) { return err; } LockGuard guard(lock); cloud_config = parsed; cloud_config_loaded = true; if (cloud_started) { startCloudLocked(); } return ESP_OK; } esp_err_t clearCloudConfig() { GatewayProvisioningStore store(cloudNamespace()); const esp_err_t err = store.clear(); if (err != ESP_OK) { return err; } LockGuard guard(lock); if (cloud != nullptr) { cloud->stop(); } cloud_config.reset(); cloud_config_loaded = false; cloud_started = false; applyCloudModelsLocked(); return ESP_OK; } esp_err_t startCloud() { LockGuard guard(lock); return startCloudLocked(); } esp_err_t startCloudLocked() { if (!service_config.cloud_enabled) { return ESP_ERR_NOT_SUPPORTED; } if (!cloud_config.has_value()) { return ESP_ERR_NOT_FOUND; } if (cloud == nullptr) { applyCloudModelsLocked(); } if (cloud->start(cloud_config.value())) { cloud_started = true; return ESP_OK; } cloud_started = false; return ESP_FAIL; } esp_err_t stopCloud() { LockGuard guard(lock); if (cloud != nullptr) { cloud->stop(); } cloud_started = false; return ESP_OK; } std::string modelName(const std::string& model_id) const { const auto model = std::find_if(bridge_config.models.begin(), bridge_config.models.end(), [&model_id](const auto& item) { return item.id == model_id; }); return model == bridge_config.models.end() ? model_id : model->displayName(); } #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) GatewayBacnetServerConfig bacnetServerConfigLocked() const { GatewayBacnetServerConfig config; config.device_name = channel.name.empty() ? "DALI Gateway " + std::to_string(channel.gateway_id) : channel.name; config.task_stack_size = service_config.bacnet_task_stack_size; config.task_priority = service_config.bacnet_task_priority; if (bacnet != nullptr) { const auto& bridge_config = bacnet->config(); config.device_instance = bridge_config.deviceInstance; config.local_address = bridge_config.localAddress; config.udp_port = bridge_config.udpPort; } return config; } std::vector bacnetObjectBindingsLocked() const { std::vector bindings; if (bacnet == nullptr) { return bindings; } for (const auto& binding : bacnet->describeObjects()) { if (binding.objectInstance < 0) { continue; } bindings.push_back(GatewayBacnetObjectBinding{channel.gateway_id, binding.modelID, modelName(binding.modelID), binding.objectType, static_cast(binding.objectInstance), binding.property.empty() ? "presentValue" : binding.property}); } return bindings; } bool handleBacnetWrite(BridgeObjectType object_type, uint32_t object_instance, const std::string& property, const DaliValue& value) { LockGuard guard(lock); if (bacnet == nullptr) { return false; } const DaliBridgeResult result = bacnet->handlePropertyWrite( object_type, static_cast(object_instance), property, value); if (!result.ok) { ESP_LOGW(kTag, "gateway=%u BACnet write rejected: %s", channel.gateway_id, result.error.c_str()); } return result.ok; } esp_err_t startBacnet() { LockGuard guard(lock); if (!service_config.bacnet_enabled) { return ESP_ERR_NOT_SUPPORTED; } if (bacnet == nullptr) { return ESP_ERR_INVALID_STATE; } const auto bindings = bacnetObjectBindingsLocked(); if (bindings.empty()) { return ESP_ERR_NOT_FOUND; } const auto server_config = bacnetServerConfigLocked(); const esp_err_t err = GatewayBacnetServer::instance().registerChannel( channel.gateway_id, server_config, bindings, [this](BridgeObjectType object_type, uint32_t object_instance, const std::string& property, const DaliValue& value) { return handleBacnetWrite(object_type, object_instance, property, value); }); bacnet_started = err == ESP_OK; return err; } #else esp_err_t startBacnet() { return ESP_ERR_NOT_SUPPORTED; } #endif GatewayBridgeHttpResponse execute(std::string_view json) { cJSON* root = cJSON_ParseWithLength(json.data(), json.size()); if (root == nullptr) { return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid execute JSON"); } const DaliBridgeRequest request = BridgeRequestFromJson(root); cJSON_Delete(root); LockGuard guard(lock); if (engine == nullptr) { return ErrorResponse(ESP_ERR_INVALID_STATE, "bridge engine is not ready"); } const DaliBridgeResult result = engine->execute(request); return JsonOk(BridgeResultToCjson(result)); } cJSON* statusCjson() const { cJSON* root = cJSON_CreateObject(); if (root == nullptr) { return nullptr; } cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); cJSON_AddNumberToObject(root, "channel", channel.channel_index); cJSON_AddStringToObject(root, "name", channel.name.c_str()); cJSON_AddBoolToObject(root, "bridgeConfigLoaded", bridge_config_loaded); cJSON_AddNumberToObject(root, "modelCount", static_cast(bridge_config.models.size())); cJSON* modbus_json = cJSON_CreateObject(); if (modbus_json != nullptr) { cJSON_AddBoolToObject(modbus_json, "enabled", service_config.modbus_enabled); cJSON_AddBoolToObject(modbus_json, "started", modbus_started); if (bridge_config.modbus.has_value()) { cJSON_AddStringToObject(modbus_json, "transport", bridge_config.modbus->transport.c_str()); cJSON_AddNumberToObject(modbus_json, "port", bridge_config.modbus->port); cJSON_AddNumberToObject(modbus_json, "unitID", bridge_config.modbus->unitID); } cJSON_AddItemToObject(root, "modbus", modbus_json); } cJSON* bacnet_json = cJSON_CreateObject(); if (bacnet_json != nullptr) { #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) const auto server_status = GatewayBacnetServer::instance().status(); #endif cJSON_AddBoolToObject(bacnet_json, "enabled", service_config.bacnet_enabled); cJSON_AddBoolToObject(bacnet_json, "startupEnabled", service_config.bacnet_startup_enabled); cJSON_AddBoolToObject(bacnet_json, "started", bacnet_started); #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) cJSON_AddBoolToObject(bacnet_json, "serverStarted", server_status.started); cJSON_AddNumberToObject(bacnet_json, "serverObjectCount", static_cast(server_status.object_count)); #else cJSON_AddBoolToObject(bacnet_json, "serverStarted", false); cJSON_AddNumberToObject(bacnet_json, "serverObjectCount", 0); #endif if (bridge_config.bacnet.has_value()) { cJSON_AddNumberToObject(bacnet_json, "deviceInstance", bridge_config.bacnet->deviceInstance); cJSON_AddStringToObject(bacnet_json, "localAddress", bridge_config.bacnet->localAddress.c_str()); cJSON_AddNumberToObject(bacnet_json, "udpPort", bridge_config.bacnet->udpPort); } cJSON_AddItemToObject(root, "bacnet", bacnet_json); } cJSON* cloud_json = cJSON_CreateObject(); if (cloud_json != nullptr) { cJSON_AddBoolToObject(cloud_json, "enabled", service_config.cloud_enabled); cJSON_AddBoolToObject(cloud_json, "configured", cloud_config_loaded); cJSON_AddBoolToObject(cloud_json, "started", cloud_started); cJSON_AddBoolToObject(cloud_json, "connected", cloud != nullptr && cloud->isConnected()); if (cloud_config.has_value()) { cJSON_AddStringToObject(cloud_json, "deviceID", cloud_config->deviceID.c_str()); cJSON_AddStringToObject(cloud_json, "topicPrefix", cloud_config->topicPrefix.c_str()); } cJSON_AddItemToObject(root, "cloud", cloud_json); } return root; } GatewayBridgeHttpResponse configJson() const { return GatewayBridgeHttpResponse{ESP_OK, BridgeRuntimeConfigToJson(bridge_config)}; } GatewayBridgeHttpResponse modbusBindingsJson() const { cJSON* root = cJSON_CreateObject(); cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); cJSON* bindings = cJSON_CreateArray(); if (bindings != nullptr && modbus != nullptr) { for (const auto& binding : modbus->describeHoldingRegisters()) { cJSON* item = cJSON_CreateObject(); if (item == nullptr) { continue; } cJSON_AddStringToObject(item, "model", binding.modelID.c_str()); cJSON_AddNumberToObject(item, "registerAddress", binding.registerAddress); cJSON_AddItemToArray(bindings, item); } } cJSON_AddItemToObject(root, "bindings", bindings); return JsonOk(root); } GatewayBridgeHttpResponse bacnetBindingsJson() const { cJSON* root = cJSON_CreateObject(); cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); cJSON* bindings = cJSON_CreateArray(); #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) if (bindings != nullptr && bacnet != nullptr) { for (const auto& binding : bacnet->describeObjects()) { cJSON* item = cJSON_CreateObject(); if (item == nullptr) { continue; } cJSON_AddStringToObject(item, "model", binding.modelID.c_str()); cJSON_AddStringToObject(item, "objectType", bridgeObjectTypeToString(binding.objectType)); cJSON_AddNumberToObject(item, "objectInstance", binding.objectInstance); cJSON_AddStringToObject(item, "property", binding.property.c_str()); cJSON_AddItemToArray(bindings, item); } } #endif cJSON_AddItemToObject(root, "bindings", bindings); cJSON* server_json = cJSON_CreateObject(); if (server_json != nullptr) { #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) const auto server_status = GatewayBacnetServer::instance().status(); cJSON_AddBoolToObject(server_json, "started", server_status.started); cJSON_AddNumberToObject(server_json, "deviceInstance", server_status.device_instance); cJSON_AddNumberToObject(server_json, "udpPort", server_status.udp_port); cJSON_AddNumberToObject(server_json, "channelCount", static_cast(server_status.channel_count)); cJSON_AddNumberToObject(server_json, "objectCount", static_cast(server_status.object_count)); #else cJSON_AddBoolToObject(server_json, "started", false); cJSON_AddNumberToObject(server_json, "deviceInstance", 0); cJSON_AddNumberToObject(server_json, "udpPort", 0); cJSON_AddNumberToObject(server_json, "channelCount", 0); cJSON_AddNumberToObject(server_json, "objectCount", 0); #endif cJSON_AddItemToObject(root, "server", server_json); } return JsonOk(root); } GatewayBridgeHttpResponse cloudJson() const { cJSON* root = cJSON_CreateObject(); cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); cJSON_AddBoolToObject(root, "configured", cloud_config_loaded); cJSON_AddBoolToObject(root, "started", cloud_started); cJSON_AddBoolToObject(root, "connected", cloud != nullptr && cloud->isConnected()); if (cloud_config.has_value()) { cJSON_AddItemToObject(root, "config", GatewayCloudConfigToCjson(cloud_config.value())); } return JsonOk(root); } esp_err_t startModbus(std::set* used_ports = nullptr) { LockGuard guard(lock); if (!service_config.modbus_enabled) { return ESP_ERR_NOT_SUPPORTED; } if (modbus_started || modbus_task_handle != nullptr) { return ESP_OK; } if (!bridge_config.modbus.has_value()) { return ESP_ERR_NOT_FOUND; } const uint16_t port = bridge_config.modbus->port == 0 ? kDefaultModbusPort : bridge_config.modbus->port; if (used_ports != nullptr) { if (used_ports->find(port) != used_ports->end()) { ESP_LOGW(kTag, "gateway=%u skips duplicate Modbus TCP port %u", channel.gateway_id, port); return ESP_ERR_INVALID_STATE; } used_ports->insert(port); } const BaseType_t created = xTaskCreate(&ChannelRuntime::ModbusTaskEntry, "gw_modbus_tcp", service_config.modbus_task_stack_size, this, service_config.modbus_task_priority, &modbus_task_handle); if (created != pdPASS) { modbus_task_handle = nullptr; return ESP_ERR_NO_MEM; } modbus_started = true; return ESP_OK; } void modbusTaskLoop() { const uint16_t port = bridge_config.modbus.has_value() && bridge_config.modbus->port != 0 ? bridge_config.modbus->port : kDefaultModbusPort; const int listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_IP); if (listen_sock < 0) { ESP_LOGE(kTag, "gateway=%u failed to create Modbus socket", channel.gateway_id); modbus_started = false; modbus_task_handle = nullptr; vTaskDelete(nullptr); return; } int reuse = 1; setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); sockaddr_in address = {}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_ANY); address.sin_port = htons(port); if (bind(listen_sock, reinterpret_cast(&address), sizeof(address)) != 0 || listen(listen_sock, 2) != 0) { ESP_LOGE(kTag, "gateway=%u failed to bind Modbus TCP port %u", channel.gateway_id, port); close(listen_sock); modbus_started = false; modbus_task_handle = nullptr; vTaskDelete(nullptr); return; } ESP_LOGI(kTag, "gateway=%u Modbus TCP listening on port %u", channel.gateway_id, port); while (true) { sockaddr_in client_address = {}; socklen_t client_len = sizeof(client_address); const int client_sock = accept(listen_sock, reinterpret_cast(&client_address), &client_len); if (client_sock < 0) { continue; } handleModbusClient(client_sock); close(client_sock); } } void handleModbusClient(int client_sock) { uint8_t header[7] = {}; while (RecvAll(client_sock, header, sizeof(header))) { const uint16_t protocol_id = ReadBe16(&header[2]); const uint16_t length = ReadBe16(&header[4]); if (protocol_id != 0 || length < 2 || length > kModbusMaxPduBytes) { break; } std::vector pdu(length - 1); if (!RecvAll(client_sock, pdu.data(), pdu.size()) || pdu.empty()) { break; } if (bridge_config.modbus.has_value() && bridge_config.modbus->unitID != 0 && header[6] != bridge_config.modbus->unitID) { SendModbusException(client_sock, header, pdu[0], 0x0B); continue; } if (pdu[0] == 0x06 && pdu.size() == 5) { const uint16_t wire_register = ReadBe16(&pdu[1]); const uint16_t value = ReadBe16(&pdu[3]); const int holding_register = HoldingRegisterFromWireAddress(wire_register); const auto result = handleHoldingRegisterWrite(holding_register, value); if (!result.ok) { SendModbusException(client_sock, header, pdu[0], 0x04); continue; } SendModbusFrame(client_sock, header, pdu); continue; } if (pdu[0] == 0x10 && pdu.size() >= 6) { const uint16_t start_register = ReadBe16(&pdu[1]); const uint16_t quantity = ReadBe16(&pdu[3]); const uint8_t byte_count = pdu[5]; if (pdu.size() != static_cast(6 + byte_count) || byte_count != quantity * 2) { SendModbusException(client_sock, header, pdu[0], 0x03); continue; } bool ok = true; for (uint16_t index = 0; index < quantity; ++index) { const size_t offset = 6 + (index * 2); const uint16_t value = ReadBe16(&pdu[offset]); const int holding_register = HoldingRegisterFromWireAddress(start_register + index); const auto result = handleHoldingRegisterWrite(holding_register, value); if (!result.ok) { ok = false; break; } } if (!ok) { SendModbusException(client_sock, header, pdu[0], 0x04); continue; } std::vector response(5); response[0] = pdu[0]; WriteBe16(&response[1], start_register); WriteBe16(&response[3], quantity); SendModbusFrame(client_sock, header, response); continue; } SendModbusException(client_sock, header, pdu[0], 0x01); } } DaliBridgeResult handleHoldingRegisterWrite(int holding_register, int value) { LockGuard guard(lock); if (modbus == nullptr) { DaliBridgeResult result; result.sequence = "modbus-" + std::to_string(holding_register); result.error = "modbus bridge not ready"; return result; } return modbus->handleHoldingRegisterWrite(holding_register, value); } }; GatewayBridgeService::GatewayBridgeService(DaliDomainService& dali_domain, GatewayBridgeServiceConfig config) : dali_domain_(dali_domain), config_(config) {} GatewayBridgeService::~GatewayBridgeService() = default; esp_err_t GatewayBridgeService::start() { if (!config_.bridge_enabled) { ESP_LOGI(kTag, "bridge service disabled"); return ESP_OK; } if (!runtimes_.empty()) { return ESP_OK; } const auto channels = dali_domain_.channelInfo(); runtimes_.reserve(channels.size()); for (const auto& channel : channels) { auto runtime = std::make_unique(dali_domain_, channel, config_); const esp_err_t err = runtime->start(); if (err != ESP_OK) { ESP_LOGE(kTag, "failed to start bridge runtime gateway=%u: %s", channel.gateway_id, esp_err_to_name(err)); return err; } runtimes_.push_back(std::move(runtime)); } if (config_.modbus_enabled && config_.modbus_startup_enabled) { std::set used_ports; for (const auto& runtime : runtimes_) { const esp_err_t err = runtime->startModbus(&used_ports); if (err != ESP_OK && err != ESP_ERR_NOT_FOUND && err != ESP_ERR_NOT_SUPPORTED) { ESP_LOGW(kTag, "gateway=%u Modbus startup skipped: %s", runtime->channel.gateway_id, esp_err_to_name(err)); } } } if (config_.bacnet_enabled && config_.bacnet_startup_enabled) { for (const auto& runtime : runtimes_) { const esp_err_t err = runtime->startBacnet(); if (err != ESP_OK && err != ESP_ERR_NOT_FOUND && err != ESP_ERR_NOT_SUPPORTED) { ESP_LOGW(kTag, "gateway=%u BACnet startup skipped: %s", runtime->channel.gateway_id, esp_err_to_name(err)); } } } ESP_LOGI(kTag, "bridge service started channels=%u", static_cast(runtimes_.size())); return ESP_OK; } GatewayBridgeService::ChannelRuntime* GatewayBridgeService::findRuntime(uint8_t gateway_id) { for (const auto& runtime : runtimes_) { if (runtime->channel.gateway_id == gateway_id) { return runtime.get(); } } return nullptr; } const GatewayBridgeService::ChannelRuntime* GatewayBridgeService::findRuntime( uint8_t gateway_id) const { for (const auto& runtime : runtimes_) { if (runtime->channel.gateway_id == gateway_id) { return runtime.get(); } } return nullptr; } GatewayBridgeHttpResponse GatewayBridgeService::handleGet( const std::string& action_arg, int gateway_id_arg, const std::string& query_arg) const { if (!config_.bridge_enabled) { return ErrorResponse(ESP_ERR_NOT_SUPPORTED, "bridge service is disabled"); } std::string_view action(action_arg); std::string_view query(query_arg); std::optional gateway_id; if (gateway_id_arg >= 0 && gateway_id_arg <= 255) { gateway_id = static_cast(gateway_id_arg); } if (action.empty()) { action = "status"; } if (action == "status" && !gateway_id.has_value()) { cJSON* root = cJSON_CreateObject(); cJSON_AddBoolToObject(root, "enabled", true); cJSON_AddNumberToObject(root, "count", static_cast(runtimes_.size())); cJSON* channels = cJSON_CreateArray(); if (channels != nullptr) { for (const auto& runtime : runtimes_) { cJSON_AddItemToArray(channels, runtime->statusCjson()); } cJSON_AddItemToObject(root, "channels", channels); } return JsonOk(root); } if (!gateway_id.has_value()) { return ErrorResponse(ESP_ERR_INVALID_ARG, "gateway id is required"); } const auto* runtime = findRuntime(gateway_id.value()); if (runtime == nullptr) { return ErrorResponse(ESP_ERR_NOT_FOUND, "unknown gateway id"); } if (action == "status") { return JsonOk(runtime->statusCjson()); } if (action == "config") { return runtime->configJson(); } if (action == "modbus") { return runtime->modbusBindingsJson(); } if (action == "bacnet") { return runtime->bacnetBindingsJson(); } if (action == "cloud") { return runtime->cloudJson(); } if (action == "device") { const auto address = QueryInt(query, "addr", "address"); if (!address.has_value() || !ValidDaliAddress(address.value())) { return ErrorResponse(ESP_ERR_INVALID_ARG, "valid addr is required"); } return SnapshotResponse(dali_domain_.discoverDeviceTypes(gateway_id.value(), address.value()), "device did not respond to type discovery"); } if (action == "dt4" || action == "dt5" || action == "dt6") { const auto address = QueryInt(query, "addr", "address"); if (!address.has_value() || !ValidDaliAddress(address.value())) { return ErrorResponse(ESP_ERR_INVALID_ARG, "valid addr is required"); } if (action == "dt4") { return SnapshotResponse(dali_domain_.dt4Snapshot(gateway_id.value(), address.value()), "DT4 snapshot is unavailable"); } if (action == "dt5") { return SnapshotResponse(dali_domain_.dt5Snapshot(gateway_id.value(), address.value()), "DT5 snapshot is unavailable"); } return SnapshotResponse(dali_domain_.dt6Snapshot(gateway_id.value(), address.value()), "DT6 snapshot is unavailable"); } if (action == "dt8_scene") { const auto address = QueryInt(query, "addr", "address"); const auto scene = QueryInt(query, "scene"); if (!address.has_value() || !scene.has_value() || !ValidDaliAddress(address.value()) || scene.value() < 0 || scene.value() > 15) { return ErrorResponse(ESP_ERR_INVALID_ARG, "valid addr and scene are required"); } return SnapshotResponse(dali_domain_.dt8SceneColorReport(gateway_id.value(), address.value(), scene.value()), "DT8 scene color report is unavailable"); } if (action == "dt8_power_on") { const auto address = QueryInt(query, "addr", "address"); if (!address.has_value() || !ValidDaliAddress(address.value())) { return ErrorResponse(ESP_ERR_INVALID_ARG, "valid addr is required"); } return SnapshotResponse(dali_domain_.dt8PowerOnLevelColorReport(gateway_id.value(), address.value()), "DT8 power-on color report is unavailable"); } if (action == "dt8_system_failure") { const auto address = QueryInt(query, "addr", "address"); if (!address.has_value() || !ValidDaliAddress(address.value())) { return ErrorResponse(ESP_ERR_INVALID_ARG, "valid addr is required"); } return SnapshotResponse(dali_domain_.dt8SystemFailureLevelColorReport(gateway_id.value(), address.value()), "DT8 system-failure color report is unavailable"); } return ErrorResponse(ESP_ERR_INVALID_ARG, "unknown bridge GET action"); } GatewayBridgeHttpResponse GatewayBridgeService::handlePost( const std::string& action_arg, int gateway_id_arg, const std::string& body_arg) { if (!config_.bridge_enabled) { return ErrorResponse(ESP_ERR_NOT_SUPPORTED, "bridge service is disabled"); } std::string_view action(action_arg); std::string_view body(body_arg); std::optional gateway_id; if (gateway_id_arg >= 0 && gateway_id_arg <= 255) { gateway_id = static_cast(gateway_id_arg); } cJSON* root = body.empty() ? nullptr : cJSON_ParseWithLength(body.data(), body.size()); if (!body.empty() && root == nullptr) { return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid JSON body"); } if (action.empty() && root != nullptr) { if (const char* body_action = JsonString(root, "action")) { action = body_action; } } if (!gateway_id.has_value()) { gateway_id = JsonGatewayId(root); } cJSON_Delete(root); if (action.empty()) { action = "execute"; } if (!gateway_id.has_value()) { return ErrorResponse(ESP_ERR_INVALID_ARG, "gateway id is required"); } auto* runtime = findRuntime(gateway_id.value()); if (runtime == nullptr) { return ErrorResponse(ESP_ERR_NOT_FOUND, "unknown gateway id"); } if (action == "dt8_scene_snapshot" || action == "store_dt8_scene_snapshot") { return StoreDt8SceneSnapshot(dali_domain_, gateway_id.value(), body); } if (action == "dt8_power_on_snapshot" || action == "store_dt8_power_on_snapshot") { return StoreDt8LevelSnapshot(dali_domain_, gateway_id.value(), body, true); } if (action == "dt8_system_failure_snapshot" || action == "store_dt8_system_failure_snapshot") { return StoreDt8LevelSnapshot(dali_domain_, gateway_id.value(), body, false); } if (action == "execute") { return runtime->execute(body); } if (action == "config" || action == "save_config") { const esp_err_t err = runtime->saveBridgeConfig(body); if (err != ESP_OK) { return ErrorResponse(err, "failed to save bridge config"); } return handleGet("config", gateway_id.value()); } if (action == "clear_config") { const esp_err_t err = runtime->clearBridgeConfig(); if (err != ESP_OK) { return ErrorResponse(err, "failed to clear bridge config"); } return handleGet("config", gateway_id.value()); } if (action == "cloud" || action == "save_cloud") { const esp_err_t err = runtime->saveCloudConfig(body); if (err != ESP_OK) { return ErrorResponse(err, "failed to save cloud config"); } return handleGet("cloud", gateway_id.value()); } if (action == "cloud_start") { const esp_err_t err = runtime->startCloud(); if (err != ESP_OK) { return ErrorResponse(err, "failed to start cloud bridge"); } return handleGet("cloud", gateway_id.value()); } if (action == "cloud_stop") { const esp_err_t err = runtime->stopCloud(); if (err != ESP_OK) { return ErrorResponse(err, "failed to stop cloud bridge"); } return handleGet("cloud", gateway_id.value()); } if (action == "cloud_clear") { const esp_err_t err = runtime->clearCloudConfig(); if (err != ESP_OK) { return ErrorResponse(err, "failed to clear cloud config"); } return handleGet("cloud", gateway_id.value()); } if (action == "modbus_start") { const esp_err_t err = runtime->startModbus(); if (err != ESP_OK) { return ErrorResponse(err, "failed to start Modbus TCP bridge"); } return handleGet("modbus", gateway_id.value()); } if (action == "bacnet_start") { const esp_err_t err = runtime->startBacnet(); if (err != ESP_OK) { return ErrorResponse(err, "failed to start BACnet/IP bridge"); } return handleGet("bacnet", gateway_id.value()); } return ErrorResponse(ESP_ERR_INVALID_ARG, "unknown bridge POST action"); } } // namespace gateway