#include "gateway_bridge.hpp" #include "gateway_bacnet_bridge.hpp" #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) #include "gateway_bacnet.hpp" #endif #include "bridge.hpp" #include "bridge_model.hpp" #include "bridge_provisioning.hpp" #include "dali_comm.hpp" #include "dali_define.hpp" #include "dali_domain.hpp" #include "gateway_cache.hpp" #include "gateway_cloud.hpp" #include "gateway_knx.hpp" #include "gateway_modbus.hpp" #include "gateway_provisioning.hpp" #include "cJSON.h" #include "driver/uart.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 #include #include #include #include #include #include namespace gateway { namespace { constexpr const char* kTag = "gateway_bridge"; constexpr const char* kBridgeConfigKey = "bridge_cfg"; constexpr const char* kDiscoveryInventoryKey = "bridge_disc"; constexpr int kMaxDaliShortAddress = 63; constexpr uint16_t kModbusUnknownRegister = 0xFFFF; constexpr uint16_t kModbusDiscreteInputBase = 10001; constexpr uint32_t kDiagnosticSnapshotCacheTtlMs = 500; constexpr uint32_t kBacnetGeneratedBinaryInputBase = 1000000; constexpr uint32_t kBacnetGeneratedBinaryInputChannelStride = 32768; constexpr uint32_t kBacnetMaxObjectInstance = 4194303; constexpr uint32_t kBacnetReliabilityNoFaultDetected = 0; constexpr uint32_t kBacnetReliabilityCommunicationFailure = 12; constexpr const char* kModbusManagementPrefix = "@DALIGW"; struct GatewayBridgeStoredConfig { BridgeRuntimeConfig bridge; std::optional modbus; std::optional knx; std::optional bacnet_server; }; struct BridgeDiscoveryEntry { int short_address{0}; bool online{true}; DaliDomainSnapshot discovery; }; using BridgeDiscoveryInventory = std::map; 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; } bool ValidShortAddress(int address) { return address >= 0 && address <= kMaxDaliShortAddress; } uint8_t RawArcAddressFromDec(int dec_address) { if (dec_address >= 0 && dec_address < 64) { return static_cast(dec_address * 2); } if (dec_address >= 64 && dec_address < 80) { return static_cast(0x80 + (dec_address - 64) * 2); } return 0xfe; } uint8_t RawCommandAddressFromDec(int dec_address) { if (dec_address >= 0 && dec_address < 64) { return static_cast(dec_address * 2 + 1); } if (dec_address >= 64 && dec_address < 80) { return static_cast(0x80 + (dec_address - 64) * 2 + 1); } return 0xff; } uint16_t DeviceTypeMask(const DaliDomainSnapshot& snapshot) { uint16_t mask = 0; const auto types = snapshot.int_arrays.find("types"); if (types != snapshot.int_arrays.end()) { for (const int type : types->second) { if (type >= 0 && type < 16) { mask |= static_cast(1U << type); } } } return mask; } bool IsRawBridgeOperation(BridgeOperation operation) { switch (operation) { case BridgeOperation::send: case BridgeOperation::sendExt: case BridgeOperation::query: return true; default: return false; } } bool SnapshotHasDeviceType(const DaliDomainSnapshot& snapshot, int device_type) { const auto primary = snapshot.ints.find("primaryType"); if (primary != snapshot.ints.end() && primary->second == device_type) { return true; } const auto types = snapshot.int_arrays.find("types"); return types != snapshot.int_arrays.end() && std::find(types->second.begin(), types->second.end(), device_type) != types->second.end(); } std::optional SnapshotBoolValue(const DaliDomainSnapshot& snapshot, std::string_view key) { for (const auto& entry : snapshot.bools) { if (std::string_view(entry.first) == key) { return entry.second; } } return std::nullopt; } std::optional SnapshotIntValue(const DaliDomainSnapshot& snapshot, const std::string& key) { const auto found = snapshot.ints.find(key); return found == snapshot.ints.end() ? std::nullopt : std::optional(found->second); } bool OperationRequiresDt1(BridgeOperation operation) { switch (operation) { case BridgeOperation::getEmergencyLevel: case BridgeOperation::getEmergencyStatus: case BridgeOperation::getEmergencyFailureStatus: case BridgeOperation::startEmergencyFunctionTest: case BridgeOperation::stopEmergencyTest: case BridgeOperation::startEmergencyDurationTest: return true; default: return false; } } bool OperationRequiresDt8(BridgeOperation operation) { switch (operation) { case BridgeOperation::setColorTemperature: case BridgeOperation::getColorTemperature: case BridgeOperation::getColorStatus: return true; default: return false; } } bool BridgeOperationReadable(BridgeOperation operation) { switch (operation) { case BridgeOperation::query: case BridgeOperation::getBrightness: case BridgeOperation::getStatus: case BridgeOperation::getColorTemperature: case BridgeOperation::getColorStatus: case BridgeOperation::getEmergencyLevel: case BridgeOperation::getEmergencyStatus: case BridgeOperation::getEmergencyFailureStatus: return true; default: return false; } } std::optional BridgeTargetValue(const BridgeDaliTarget& target) { switch (target.kind) { case BridgeDaliTargetKind::shortAddress: return target.shortAddress; case BridgeDaliTargetKind::group: return target.groupAddress; case BridgeDaliTargetKind::broadcast: return std::nullopt; } return std::nullopt; } 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; } DaliValue::Object SnapshotToValue(const DaliDomainSnapshot& snapshot) { DaliValue::Object out; out["gatewayId"] = snapshot.gateway_id; out["address"] = snapshot.address; out["kind"] = snapshot.kind; if (!snapshot.bools.empty()) { DaliValue::Object bools; for (const auto& entry : snapshot.bools) { bools[entry.first] = entry.second; } out["bools"] = std::move(bools); } if (!snapshot.ints.empty()) { DaliValue::Object ints; for (const auto& entry : snapshot.ints) { ints[entry.first] = entry.second; } out["ints"] = std::move(ints); } if (!snapshot.numbers.empty()) { DaliValue::Object numbers; for (const auto& entry : snapshot.numbers) { numbers[entry.first] = entry.second; } out["numbers"] = std::move(numbers); } if (!snapshot.int_arrays.empty()) { DaliValue::Object arrays; for (const auto& entry : snapshot.int_arrays) { DaliValue::Array values; values.reserve(entry.second.size()); for (const int value : entry.second) { values.emplace_back(value); } arrays[entry.first] = std::move(values); } out["intArrays"] = std::move(arrays); } if (!snapshot.number_arrays.empty()) { DaliValue::Object arrays; for (const auto& entry : snapshot.number_arrays) { DaliValue::Array values; values.reserve(entry.second.size()); for (const double value : entry.second) { values.emplace_back(value); } arrays[entry.first] = std::move(values); } out["numberArrays"] = std::move(arrays); } return out; } std::optional SnapshotFromValue(const DaliValue* value) { if (value == nullptr) { return std::nullopt; } const auto* object = value->asObject(); if (object == nullptr) { return std::nullopt; } DaliDomainSnapshot snapshot; snapshot.gateway_id = static_cast(getObjectInt(*object, "gatewayId").value_or(0)); snapshot.address = getObjectInt(*object, "address").value_or(0); snapshot.kind = getObjectString(*object, "kind").value_or("device"); if (const auto* bools = getObjectValue(*object, "bools")) { if (const auto* bool_object = bools->asObject()) { for (const auto& entry : *bool_object) { if (const auto parsed = entry.second.asBool()) { snapshot.bools[entry.first] = parsed.value(); } } } } if (const auto* ints = getObjectValue(*object, "ints")) { if (const auto* int_object = ints->asObject()) { for (const auto& entry : *int_object) { if (const auto parsed = entry.second.asInt()) { snapshot.ints[entry.first] = parsed.value(); } } } } if (const auto* numbers = getObjectValue(*object, "numbers")) { if (const auto* number_object = numbers->asObject()) { for (const auto& entry : *number_object) { if (const auto parsed = entry.second.asDouble()) { snapshot.numbers[entry.first] = parsed.value(); } } } } if (const auto* int_arrays = getObjectValue(*object, "intArrays")) { if (const auto* array_object = int_arrays->asObject()) { for (const auto& entry : *array_object) { const auto* array = entry.second.asArray(); if (array == nullptr) { continue; } std::vector values; values.reserve(array->size()); for (const auto& item : *array) { values.push_back(item.asInt().value_or(0)); } snapshot.int_arrays[entry.first] = std::move(values); } } } if (const auto* number_arrays = getObjectValue(*object, "numberArrays")) { if (const auto* array_object = number_arrays->asObject()) { for (const auto& entry : *array_object) { const auto* array = entry.second.asArray(); if (array == nullptr) { continue; } std::vector values; values.reserve(array->size()); for (const auto& item : *array) { values.push_back(item.asDouble().value_or(0.0)); } snapshot.number_arrays[entry.first] = std::move(values); } } } return snapshot; } const char* DiscoveryStateString(bool online) { return online ? "online" : "offline"; } const char* BacnetReliabilityToString(uint32_t reliability) { switch (reliability) { case kBacnetReliabilityCommunicationFailure: return "communication_failure"; case kBacnetReliabilityNoFaultDetected: default: return "no_fault_detected"; } } bool SnapshotSupportsDeviceType(const DaliDomainSnapshot& snapshot, int device_type) { return SnapshotHasDeviceType(snapshot, device_type); } void AddDiscoveryCapabilities(cJSON* root, const DaliDomainSnapshot& snapshot) { if (root == nullptr) { return; } cJSON_AddBoolToObject(root, "supportsDt1", SnapshotSupportsDeviceType(snapshot, 1)); cJSON_AddBoolToObject(root, "supportsDt4", SnapshotSupportsDeviceType(snapshot, 4)); cJSON_AddBoolToObject(root, "supportsDt5", SnapshotSupportsDeviceType(snapshot, 5)); cJSON_AddBoolToObject(root, "supportsDt6", SnapshotSupportsDeviceType(snapshot, 6)); cJSON_AddBoolToObject(root, "supportsDt8", SnapshotSupportsDeviceType(snapshot, 8)); } cJSON* DiscoveryEntryToCjson(const BridgeDiscoveryEntry& entry) { cJSON* root = SnapshotToCjson(entry.discovery); if (root == nullptr) { return nullptr; } cJSON_AddBoolToObject(root, "discovered", true); cJSON_AddBoolToObject(root, "online", entry.online); cJSON_AddStringToObject(root, "inventoryState", DiscoveryStateString(entry.online)); AddDiscoveryCapabilities(root, entry.discovery); return root; } cJSON* MissingDiscoveryEntryToCjson(uint8_t gateway_id, int short_address) { cJSON* root = cJSON_CreateObject(); if (root == nullptr) { return nullptr; } cJSON_AddNumberToObject(root, "gatewayId", gateway_id); cJSON_AddNumberToObject(root, "address", short_address); cJSON_AddStringToObject(root, "kind", "device"); cJSON_AddBoolToObject(root, "discovered", false); cJSON_AddBoolToObject(root, "online", false); cJSON_AddStringToObject(root, "inventoryState", "never_seen"); return root; } DaliValue::Object DiscoveryEntryToValue(const BridgeDiscoveryEntry& entry) { DaliValue::Object out; out["shortAddress"] = entry.short_address; out["state"] = DiscoveryStateString(entry.online); out["snapshot"] = SnapshotToValue(entry.discovery); return out; } std::optional DiscoveryEntryFromValue(const DaliValue& value) { const auto* object = value.asObject(); if (object == nullptr) { return std::nullopt; } const auto short_address = getObjectInt(*object, "shortAddress"); const auto snapshot = SnapshotFromValue(getObjectValue(*object, "snapshot")); if (!short_address.has_value() || !snapshot.has_value() || !ValidShortAddress(short_address.value())) { return std::nullopt; } const std::string state = getObjectString(*object, "state").value_or("online"); BridgeDiscoveryEntry entry; entry.short_address = short_address.value(); entry.online = state != "offline"; entry.discovery = snapshot.value(); entry.discovery.address = short_address.value(); return entry; } DaliValue::Object DiscoveryInventoryToValue(const BridgeDiscoveryInventory& inventory) { DaliValue::Object out; DaliValue::Array entries; entries.reserve(inventory.size()); for (const auto& item : inventory) { entries.emplace_back(DiscoveryEntryToValue(item.second)); } out["entries"] = std::move(entries); return out; } BridgeDiscoveryInventory DiscoveryInventoryFromValue(const DaliValue::Object& object) { BridgeDiscoveryInventory inventory; const auto* entries = getObjectValue(object, "entries"); if (entries == nullptr || entries->asArray() == nullptr) { return inventory; } for (const auto& item : *entries->asArray()) { const auto entry = DiscoveryEntryFromValue(item); if (!entry.has_value()) { continue; } inventory[entry->short_address] = entry.value(); } return inventory; } 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(); } DaliValue::Object GatewayBridgeStoredConfigToValue( const BridgeRuntimeConfig& bridge_config, const std::optional& modbus_config, const std::optional& knx_config, const std::optional& bacnet_server_config) { DaliValue::Object out = bridge_config.toJson(); if (modbus_config.has_value()) { out["modbus"] = GatewayModbusConfigToValue(modbus_config.value()); } if (knx_config.has_value()) { out["knx"] = GatewayKnxConfigToValue(knx_config.value()); } if (bacnet_server_config.has_value()) { DaliValue::Object bacnet; bacnet["deviceInstance"] = static_cast(bacnet_server_config->deviceInstance); bacnet["localAddress"] = bacnet_server_config->localAddress; bacnet["udpPort"] = static_cast(bacnet_server_config->udpPort); out["bacnetServer"] = std::move(bacnet); } return out; } std::string GatewayBridgeStoredConfigToJson( const BridgeRuntimeConfig& bridge_config, const std::optional& modbus_config, const std::optional& knx_config, const std::optional& bacnet_server_config) { cJSON* root = ToCjson(DaliValue(GatewayBridgeStoredConfigToValue( bridge_config, modbus_config, knx_config, bacnet_server_config))); const std::string body = PrintJson(root); cJSON_Delete(root); return body; } std::optional GatewayBacnetBridgeConfigFromValue( const DaliValue* value) { if (value == nullptr || value->asObject() == nullptr) { return std::nullopt; } const auto& json = *value->asObject(); GatewayBacnetBridgeConfig config; config.deviceInstance = static_cast( getObjectInt(json, "deviceInstance").value_or(4194303)); config.localAddress = getObjectString(json, "localAddress").value_or(""); config.udpPort = static_cast(getObjectInt(json, "udpPort").value_or(47808)); return config; } GatewayBridgeStoredConfig GatewayBridgeStoredConfigFromValue(const DaliValue::Object& object) { GatewayBridgeStoredConfig config; config.bridge = BridgeRuntimeConfig::fromJson(object); config.modbus = GatewayModbusConfigFromValue(getObjectValue(object, "modbus")); config.knx = GatewayKnxConfigFromValue(getObjectValue(object, "knx")); config.bacnet_server = GatewayBacnetBridgeConfigFromValue( getObjectValue(object, "bacnetServer")); return config; } std::optional GatewayBridgeStoredConfigFromJson(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 GatewayBridgeStoredConfigFromValue(*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; } bool IsKnownBridgeRequestKey(const char* key) { if (key == nullptr) return true; static const char* known[] = {"type", "seq", "sequence", "model", "modelID", "modelId", "op", "operation", "addr", "rawAddress", "cmd", "rawCommand", "shortAddress", "short_address", "value", "meta"}; for (const char* item : known) { if (std::strcmp(key, item) == 0) return true; } return false; } DaliBridgeRequest BridgeRequestFromJson(cJSON* root) { DaliBridgeRequest request; if (const char* seq = JsonString(root, "seq")) { request.sequence = seq; } if (request.sequence.empty()) { if (const char* seq = JsonString(root, "sequence")) { request.sequence = seq; } } if (const char* model = JsonString(root, "model")) { request.modelID = model; } if (request.modelID.empty()) { if (const char* model = JsonString(root, "modelID")) { request.modelID = model; } } if (const char* op = JsonString(root, "op")) { request.operation = bridgeOperationFromString(op); } if (!request.operation.has_value()) { if (const char* op = JsonString(root, "operation")) { request.operation = bridgeOperationFromString(op); } } if (const auto addr = JsonInt(root, "addr")) { request.rawAddress = addr.value(); } if (!request.rawAddress.has_value()) { if (const auto addr = JsonInt(root, "rawAddress")) { request.rawAddress = addr.value(); } } if (const auto cmd = JsonInt(root, "cmd")) { request.rawCommand = cmd.value(); } if (!request.rawCommand.has_value()) { if (const auto cmd = JsonInt(root, "rawCommand")) { request.rawCommand = cmd.value(); } } if (const auto short_address = JsonInt(root, "shortAddress")) { request.shortAddress = short_address.value(); } if (!request.shortAddress.has_value()) { if (const auto short_address = JsonInt(root, "short_address")) { 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; } } for (const cJSON* child = root != nullptr ? root->child : nullptr; child != nullptr; child = child->next) { if (!IsKnownBridgeRequestKey(child->string)) { request.metadata[child->string] = FromCjson(child); } } 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()); } std::vector ModbusExceptionPdu(uint8_t function_code, uint8_t exception_code) { return {static_cast(function_code | 0x80), exception_code}; } uint16_t ModbusCrc16(const uint8_t* data, size_t len) { uint16_t crc = 0xFFFF; for (size_t i = 0; i < len; ++i) { crc ^= data[i]; for (int bit = 0; bit < 8; ++bit) { if ((crc & 0x0001) != 0) { crc = static_cast((crc >> 1) ^ 0xA001); } else { crc = static_cast(crc >> 1); } } } return crc; } uint8_t ModbusAsciiLrc(const uint8_t* data, size_t len) { uint8_t sum = 0; for (size_t i = 0; i < len; ++i) { sum = static_cast(sum + data[i]); } return static_cast(-sum); } std::optional HexNibble(char ch) { if (ch >= '0' && ch <= '9') { return static_cast(ch - '0'); } if (ch >= 'A' && ch <= 'F') { return static_cast(ch - 'A' + 10); } if (ch >= 'a' && ch <= 'f') { return static_cast(ch - 'a' + 10); } return std::nullopt; } std::optional> DecodeModbusAsciiLine(std::string_view line) { while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) { line.remove_suffix(1); } if (line.size() < 7 || line.front() != ':' || ((line.size() - 1) % 2) != 0) { return std::nullopt; } std::vector bytes; bytes.reserve((line.size() - 1) / 2); for (size_t i = 1; i + 1 < line.size(); i += 2) { const auto high = HexNibble(line[i]); const auto low = HexNibble(line[i + 1]); if (!high.has_value() || !low.has_value()) { return std::nullopt; } bytes.push_back(static_cast((high.value() << 4) | low.value())); } uint8_t sum = 0; for (const auto byte : bytes) { sum = static_cast(sum + byte); } if (sum != 0) { return std::nullopt; } return bytes; } std::string EncodeModbusAsciiLine(const std::vector& bytes) { constexpr char kHex[] = "0123456789ABCDEF"; std::string out; out.reserve(1 + bytes.size() * 2 + 2); out.push_back(':'); for (const auto byte : bytes) { out.push_back(kHex[(byte >> 4) & 0x0F]); out.push_back(kHex[byte & 0x0F]); } out.append("\r\n"); return out; } bool LineStartsWith(std::string_view line, std::string_view prefix) { return line.size() >= prefix.size() && line.substr(0, prefix.size()) == prefix; } uart_word_length_t UartWordLength(int bits) { return bits <= 7 ? UART_DATA_7_BITS : UART_DATA_8_BITS; } uart_parity_t UartParity(const std::string& parity) { if (parity == "even") { return UART_PARITY_EVEN; } if (parity == "odd") { return UART_PARITY_ODD; } return UART_PARITY_DISABLE; } uart_stop_bits_t UartStopBits(int bits) { return bits >= 2 ? UART_STOP_BITS_2 : UART_STOP_BITS_1; } } // namespace struct GatewayBridgeService::ChannelRuntime { explicit ChannelRuntime(DaliDomainService& domain, GatewayCache& cache, DaliChannelInfo channel, GatewayBridgeServiceConfig service_config) : domain(domain), cache(cache), 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; GatewayCache& cache; DaliChannelInfo channel; GatewayBridgeServiceConfig service_config; SemaphoreHandle_t lock{nullptr}; std::unique_ptr comm; std::unique_ptr engine; std::unique_ptr modbus; std::unique_ptr knx; std::unique_ptr knx_router; #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) std::unique_ptr bacnet; #endif std::unique_ptr cloud; BridgeRuntimeConfig bridge_config; std::optional modbus_config; std::optional knx_config; std::optional bacnet_server_config; BridgeDiscoveryInventory discovery_inventory; std::optional cloud_config; bool bridge_config_loaded{false}; bool discovery_inventory_loaded{false}; bool cloud_config_loaded{false}; bool cloud_started{false}; bool modbus_started{false}; bool knx_started{false}; bool bacnet_started{false}; TaskHandle_t modbus_task_handle{nullptr}; std::atomic_bool modbus_stop_requested{false}; std::atomic_bool modbus_restart_requested{false}; int modbus_listen_sock{-1}; int modbus_client_sock{-1}; int modbus_uart_port{-1}; std::string modbus_last_error; std::string knx_last_error; struct DiagnosticSnapshotCacheEntry { DaliDomainSnapshot snapshot; TickType_t captured_ticks{0}; }; std::map diagnostic_snapshot_cache; 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()); DaliValue::Object bridge_object; if (bridge_store.loadObject(kBridgeConfigKey, &bridge_object) == ESP_OK) { const auto stored_config = GatewayBridgeStoredConfigFromValue(bridge_object); bridge_config = stored_config.bridge; modbus_config = stored_config.modbus; knx_config = stored_config.knx; bacnet_server_config = stored_config.bacnet_server; bridge_config_loaded = true; } DaliValue::Object discovery_object; if (bridge_store.loadObject(kDiscoveryInventoryKey, &discovery_object) == ESP_OK) { discovery_inventory = DiscoveryInventoryFromValue(discovery_object); discovery_inventory_loaded = true; } 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 (modbus_config.has_value()) { modbus->setConfig(modbus_config.value()); } knx = std::make_unique(*engine); knx_router = std::make_unique( *knx, [this](const uint8_t* data, size_t len) { LockGuard guard(lock); if (knx == nullptr) { DaliBridgeResult result; result.error = "KNX bridge is not ready"; return result; } return knx->handleCemiFrame(data, len); }); if (knx_config.has_value()) { knx->setConfig(knx_config.value()); knx_router->setConfig(knx_config.value()); } #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) if (service_config.bacnet_enabled) { bacnet = std::make_unique(*engine); if (bacnet_server_config.has_value()) { bacnet->setConfig(bacnet_server_config.value()); } } else { bacnet.reset(); } #endif applyCloudModelsLocked(); knx_started = false; bacnet_started = false; diagnostic_snapshot_cache.clear(); } std::optional diagnosticSnapshotLocked(int short_address, std::string_view kind) { if (!ValidShortAddress(short_address) || kind.empty()) { return std::nullopt; } std::string key(kind.data(), kind.size()); key += ":"; key += std::to_string(short_address); const TickType_t now = xTaskGetTickCount(); const auto cached = diagnostic_snapshot_cache.find(key); if (cached != diagnostic_snapshot_cache.end() && (now - cached->second.captured_ticks) <= pdMS_TO_TICKS(kDiagnosticSnapshotCacheTtlMs)) { return cached->second.snapshot; } std::optional snapshot; if (kind == "base_status") { snapshot = domain.baseStatusSnapshot(channel.gateway_id, short_address); } else if (kind == "dt1") { snapshot = domain.dt1Snapshot(channel.gateway_id, short_address); } else if (kind == "dt4") { snapshot = domain.dt4Snapshot(channel.gateway_id, short_address); } else if (kind == "dt5") { snapshot = domain.dt5Snapshot(channel.gateway_id, short_address); } else if (kind == "dt6") { snapshot = domain.dt6Snapshot(channel.gateway_id, short_address); } else if (kind == "dt8_status") { snapshot = domain.dt8StatusSnapshot(channel.gateway_id, short_address); } if (snapshot.has_value()) { diagnostic_snapshot_cache[key] = DiagnosticSnapshotCacheEntry{snapshot.value(), now}; } return snapshot; } std::optional readSnapshotBoolLocked(int short_address, std::string_view kind, std::string_view bool_key) { const auto snapshot = diagnosticSnapshotLocked(short_address, kind); if (!snapshot.has_value()) { return std::nullopt; } return SnapshotBoolValue(snapshot.value(), bool_key); } std::optional readDiagnosticBoolPointLocked(const GatewayModbusPoint& point) { if (point.diagnostic_device_type > 0) { const auto* discovery = findDiscoveryEntryLocked(point.short_address); if (discovery == nullptr || !SnapshotHasDeviceType(discovery->discovery, point.diagnostic_device_type)) { return false; } } return readSnapshotBoolLocked(point.short_address, point.diagnostic_snapshot, point.diagnostic_bool); } esp_err_t saveDiscoveryInventoryLocked() const { BridgeProvisioningStore store(bridgeNamespace()); if (discovery_inventory.empty()) { return store.clearKey(kDiscoveryInventoryKey); } return store.saveObject(kDiscoveryInventoryKey, DiscoveryInventoryToValue(discovery_inventory)); } const BridgeDiscoveryEntry* findDiscoveryEntryLocked(int short_address) const { const auto entry = discovery_inventory.find(short_address); return entry == discovery_inventory.end() ? nullptr : &entry->second; } BridgeDiscoveryEntry* findDiscoveryEntryLocked(int short_address) { const auto entry = discovery_inventory.find(short_address); return entry == discovery_inventory.end() ? nullptr : &entry->second; } const BridgeDiscoveryEntry* updateDiscoveryEntryLocked(int short_address, bool persist) { if (!ValidShortAddress(short_address)) { return nullptr; } const auto snapshot = domain.discoverDeviceTypes(channel.gateway_id, short_address); if (snapshot.has_value()) { auto& entry = discovery_inventory[short_address]; entry.short_address = short_address; entry.online = true; entry.discovery = snapshot.value(); entry.discovery.address = short_address; entry.discovery.gateway_id = channel.gateway_id; if (persist) { saveDiscoveryInventoryLocked(); } return &entry; } auto* existing = findDiscoveryEntryLocked(short_address); if (existing != nullptr) { existing->online = false; if (persist) { saveDiscoveryInventoryLocked(); } } return existing; } const BridgeDiscoveryEntry* ensureDiscoveryEntryLocked(int short_address) { const auto* existing = findDiscoveryEntryLocked(short_address); if (existing != nullptr) { return existing; } return updateDiscoveryEntryLocked(short_address, true); } std::vector referencedShortAddressesLocked() const { std::set addresses; for (const auto& model : bridge_config.models) { if (model.dali.kind != BridgeDaliTargetKind::shortAddress || !model.dali.shortAddress.has_value() || !ValidShortAddress(model.dali.shortAddress.value())) { continue; } addresses.insert(model.dali.shortAddress.value()); } return std::vector(addresses.begin(), addresses.end()); } esp_err_t syncBacnetServerLocked() { #if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) 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() && !bacnet_started) { return ESP_ERR_NOT_FOUND; } const auto server_config = bacnetServerConfigLocked(); return 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); }, [this](BridgeObjectType object_type, uint32_t object_instance, const std::string& property) -> std::optional { return readBacnetValue(object_type, object_instance, property); }); #else return ESP_ERR_NOT_SUPPORTED; #endif } 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 = GatewayBridgeStoredConfigFromJson(json); if (!parsed.has_value()) { return ESP_ERR_INVALID_ARG; } { LockGuard guard(lock); const esp_err_t validation_err = validateStoredBridgeConfigLocked(parsed.value()); if (validation_err != ESP_OK) { return validation_err; } } BridgeProvisioningStore store(bridgeNamespace()); const esp_err_t err = store.saveObject( kBridgeConfigKey, GatewayBridgeStoredConfigToValue(parsed->bridge, parsed->modbus, parsed->knx, parsed->bacnet_server)); if (err != ESP_OK) { return err; } LockGuard guard(lock); bridge_config = parsed->bridge; modbus_config = parsed->modbus; knx_config = parsed->knx; bacnet_server_config = parsed->bacnet_server; 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{}; modbus_config.reset(); knx_config.reset(); bacnet_server_config.reset(); 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(); } bool shouldPublishBacnetBindingLocked(const GatewayBacnetModelBinding& binding) { if (binding.objectInstance < 0) { return false; } if (IsRawBridgeOperation(binding.operation) || binding.target.kind != BridgeDaliTargetKind::shortAddress) { return true; } if (!binding.target.shortAddress.has_value()) { return false; } const int short_address = binding.target.shortAddress.value(); const auto* discovered = ensureDiscoveryEntryLocked(short_address); if (discovered == nullptr) { return false; } if (OperationRequiresDt1(binding.operation)) { return SnapshotHasDeviceType(discovered->discovery, 1); } if (OperationRequiresDt8(binding.operation)) { return SnapshotHasDeviceType(discovered->discovery, 8); } return true; } std::vector effectiveBacnetObjectsLocked() { std::vector bindings; if (bacnet == nullptr) { return bindings; } for (const auto& binding : bacnet->describeObjects()) { if (shouldPublishBacnetBindingLocked(binding)) { bindings.push_back(binding); } } return bindings; } #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_server_config.has_value()) { config.device_instance = bacnet_server_config->deviceInstance; config.local_address = bacnet_server_config->localAddress; config.udp_port = bacnet_server_config->udpPort; } return config; } std::optional generatedBacnetBinaryInputInstance(uint16_t modbus_address) const { if (modbus_address < kModbusDiscreteInputBase) { return std::nullopt; } const uint32_t relative = static_cast(modbus_address - kModbusDiscreteInputBase); if (relative >= kBacnetGeneratedBinaryInputChannelStride) { return std::nullopt; } const uint32_t instance = kBacnetGeneratedBinaryInputBase + static_cast(channel.channel_index) * kBacnetGeneratedBinaryInputChannelStride + relative; if (instance > kBacnetMaxObjectInstance) { return std::nullopt; } return instance; } std::optional generatedBacnetPointForObjectLocked( BridgeObjectType object_type, uint32_t object_instance) const { if (object_type != BridgeObjectType::binaryInput || modbus == nullptr) { return std::nullopt; } const uint32_t channel_base = kBacnetGeneratedBinaryInputBase + static_cast(channel.channel_index) * kBacnetGeneratedBinaryInputChannelStride; if (object_instance < channel_base || object_instance >= channel_base + kBacnetGeneratedBinaryInputChannelStride) { return std::nullopt; } const uint32_t relative = object_instance - channel_base; if (relative > UINT16_MAX - kModbusDiscreteInputBase) { return std::nullopt; } const auto point = modbus->findPoint( GatewayModbusSpace::kDiscreteInput, static_cast(kModbusDiscreteInputBase + relative)); if (!point.has_value() || !point->generated) { return std::nullopt; } return point; } bool shouldPublishGeneratedBacnetPointLocked(const GatewayModbusPoint& point) { if (!point.generated || point.space != GatewayModbusSpace::kDiscreteInput || point.access != GatewayModbusAccess::kReadOnly || !ValidShortAddress(point.short_address)) { return false; } const auto* discovery = findDiscoveryEntryLocked(point.short_address); if (discovery == nullptr) { return false; } if (point.diagnostic_device_type > 0 && !SnapshotHasDeviceType(discovery->discovery, point.diagnostic_device_type)) { return false; } return generatedBacnetBinaryInputInstance(point.address).has_value(); } std::vector generatedBacnetObjectBindingsLocked() { std::vector bindings; if (modbus == nullptr) { return bindings; } std::vector generated_points; generated_points.reserve(192); for (const auto& inventory_entry : discovery_inventory) { if (!ValidShortAddress(inventory_entry.first)) { continue; } generated_points.clear(); modbus->appendGeneratedPointsForShortAddress( static_cast(inventory_entry.first), &generated_points); for (const auto& point : generated_points) { if (!shouldPublishGeneratedBacnetPointLocked(point)) { continue; } const auto* discovery = findDiscoveryEntryLocked(point.short_address); const auto object_instance = generatedBacnetBinaryInputInstance(point.address); if (discovery == nullptr || !object_instance.has_value()) { continue; } const auto binding = modbus->describePoint(point); const bool out_of_service = !discovery->online; bindings.push_back(GatewayBacnetObjectBinding{channel.gateway_id, binding.id, binding.name, BridgeObjectType::binaryInput, object_instance.value(), "presentValue", out_of_service, out_of_service ? kBacnetReliabilityCommunicationFailure : kBacnetReliabilityNoFaultDetected, true}); } } return bindings; } std::vector bacnetObjectBindingsLocked() { std::vector bindings; for (const auto& binding : effectiveBacnetObjectsLocked()) { if (binding.objectInstance < 0) { continue; } bool out_of_service = false; uint32_t reliability = kBacnetReliabilityNoFaultDetected; if (binding.target.kind == BridgeDaliTargetKind::shortAddress && binding.target.shortAddress.has_value()) { if (const auto* discovery = findDiscoveryEntryLocked(binding.target.shortAddress.value())) { if (!discovery->online) { out_of_service = true; reliability = kBacnetReliabilityCommunicationFailure; } } } bindings.push_back(GatewayBacnetObjectBinding{channel.gateway_id, binding.modelID, modelName(binding.modelID), binding.objectType, static_cast(binding.objectInstance), binding.property.empty() ? "presentValue" : binding.property, out_of_service, reliability, BridgeOperationReadable(binding.operation)}); } auto generated = generatedBacnetObjectBindingsLocked(); bindings.insert(bindings.end(), generated.begin(), generated.end()); 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; } std::optional readBacnetValue(BridgeObjectType object_type, uint32_t object_instance, const std::string& property) { LockGuard guard(lock); if (const auto point = generatedBacnetPointForObjectLocked(object_type, object_instance)) { const auto value = readGeneratedBoolPointLocked(point.value()); return value.has_value() ? std::optional(DaliValue(value.value())) : std::nullopt; } if (bacnet == nullptr) { return std::nullopt; } const auto binding = bacnet->findObject(object_type, static_cast(object_instance), property.empty() ? "presentValue" : property); if (!binding.has_value() || !BridgeOperationReadable(binding->operation)) { return std::nullopt; } const DaliBridgeResult result = bacnet->readProperty( object_type, static_cast(object_instance), property.empty() ? "presentValue" : property); if (!result.ok || !result.data.has_value()) { return std::nullopt; } if ((object_type == BridgeObjectType::binaryInput || object_type == BridgeObjectType::binaryValue || object_type == BridgeObjectType::binaryOutput) && binding->bitIndex.has_value()) { const int bit = binding->bitIndex.value(); if (bit < 0 || bit >= 32) { return std::nullopt; } return DaliValue(((result.data.value() >> bit) & 0x1) != 0); } if (object_type == BridgeObjectType::binaryInput || object_type == BridgeObjectType::binaryValue || object_type == BridgeObjectType::binaryOutput) { return DaliValue(result.data.value() != 0); } return DaliValue(result.data.value()); } 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 esp_err_t err = syncBacnetServerLocked(); bacnet_started = err == ESP_OK; return err; } #else esp_err_t startBacnet() { return ESP_ERR_NOT_SUPPORTED; } #endif esp_err_t startKnx(std::set* used_ports = nullptr, std::set* used_uarts = nullptr) { LockGuard guard(lock); if (!service_config.knx_enabled) { return ESP_ERR_NOT_SUPPORTED; } if (knx == nullptr || knx_router == nullptr) { return ESP_ERR_INVALID_STATE; } const auto config = activeKnxConfigLocked(); if (!config.has_value()) { return ESP_ERR_NOT_FOUND; } std::string validation_error; const esp_err_t validation_err = validateKnxConfigLocked( config.value(), activeModbusConfigLocked(), &validation_error); if (validation_err != ESP_OK) { knx_last_error = validation_error; return validation_err; } if (config->ip_router_enabled && used_ports != nullptr) { if (used_ports->find(config->udp_port) != used_ports->end()) { knx_last_error = "duplicate KNXnet/IP UDP port " + std::to_string(config->udp_port); return ESP_ERR_INVALID_STATE; } used_ports->insert(config->udp_port); } if (config->ip_router_enabled && used_uarts != nullptr) { const int uart_port = config->tp_uart.uart_port; if (used_uarts->find(uart_port) != used_uarts->end()) { knx_last_error = "KNX TP-UART UART" + std::to_string(uart_port) + " is already used by another runtime"; return ESP_ERR_INVALID_STATE; } used_uarts->insert(uart_port); } knx->setConfig(config.value()); knx_router->setConfig(config.value()); if (!config->ip_router_enabled) { knx_started = false; return ESP_ERR_NOT_SUPPORTED; } knx_last_error.clear(); const esp_err_t err = knx_router->start(service_config.knx_task_stack_size, service_config.knx_task_priority); knx_started = err == ESP_OK; if (err != ESP_OK) { knx_last_error = knx_router->lastError().empty() ? "failed to start KNX TP-UART router" : knx_router->lastError(); } return err; } esp_err_t stopKnx() { LockGuard guard(lock); if (knx_router != nullptr) { const esp_err_t err = knx_router->stop(); knx_started = false; return err; } knx_started = false; return ESP_OK; } 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_AddBoolToObject(root, "discoveryInventoryLoaded", discovery_inventory_loaded); cJSON_AddNumberToObject(root, "inventoryCount", static_cast(discovery_inventory.size())); 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); cJSON_AddStringToObject(modbus_json, "lastError", modbus_last_error.c_str()); if (modbus_config.has_value()) { cJSON_AddStringToObject(modbus_json, "transport", modbus_config->transport.c_str()); cJSON_AddNumberToObject(modbus_json, "port", modbus_config->port); cJSON_AddNumberToObject(modbus_json, "unitID", modbus_config->unit_id); if (GatewayModbusTransportIsSerial(modbus_config->transport)) { cJSON* serial_json = cJSON_CreateObject(); if (serial_json != nullptr) { cJSON_AddNumberToObject(serial_json, "uartPort", modbus_config->serial.uart_port); cJSON_AddNumberToObject(serial_json, "txPin", modbus_config->serial.tx_pin); cJSON_AddNumberToObject(serial_json, "rxPin", modbus_config->serial.rx_pin); cJSON_AddNumberToObject(serial_json, "baudrate", modbus_config->serial.baudrate); cJSON_AddStringToObject(serial_json, "parity", modbus_config->serial.parity.c_str()); cJSON_AddNumberToObject(serial_json, "stopBits", modbus_config->serial.stop_bits); cJSON* rs485_json = cJSON_CreateObject(); if (rs485_json != nullptr) { cJSON_AddBoolToObject(rs485_json, "enabled", modbus_config->serial.rs485.enabled); cJSON_AddNumberToObject(rs485_json, "dePin", modbus_config->serial.rs485.de_pin); cJSON_AddItemToObject(serial_json, "rs485", rs485_json); } cJSON_AddItemToObject(modbus_json, "serial", serial_json); } } } 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 (bacnet_server_config.has_value()) { cJSON_AddNumberToObject(bacnet_json, "deviceInstance", bacnet_server_config->deviceInstance); cJSON_AddStringToObject(bacnet_json, "localAddress", bacnet_server_config->localAddress.c_str()); cJSON_AddNumberToObject(bacnet_json, "udpPort", bacnet_server_config->udpPort); } cJSON_AddItemToObject(root, "bacnet", bacnet_json); } cJSON* knx_json = cJSON_CreateObject(); if (knx_json != nullptr) { const auto effective_knx = knx_config.has_value() ? knx_config : service_config.default_knx_config; cJSON_AddBoolToObject(knx_json, "enabled", service_config.knx_enabled); cJSON_AddBoolToObject(knx_json, "startupEnabled", service_config.knx_startup_enabled); cJSON_AddBoolToObject(knx_json, "started", knx_started); cJSON_AddBoolToObject(knx_json, "routerReady", knx_router != nullptr && knx_router->started()); const std::string router_error = knx_router == nullptr ? "" : knx_router->lastError(); cJSON_AddStringToObject(knx_json, "lastError", knx_last_error.empty() ? router_error.c_str() : knx_last_error.c_str()); if (effective_knx.has_value()) { cJSON_AddBoolToObject(knx_json, "daliRouterEnabled", effective_knx->dali_router_enabled); cJSON_AddBoolToObject(knx_json, "ipRouterEnabled", effective_knx->ip_router_enabled); cJSON_AddBoolToObject(knx_json, "tunnelEnabled", effective_knx->tunnel_enabled); cJSON_AddBoolToObject(knx_json, "multicastEnabled", effective_knx->multicast_enabled); cJSON_AddNumberToObject(knx_json, "mainGroup", effective_knx->main_group); cJSON_AddNumberToObject(knx_json, "udpPort", effective_knx->udp_port); cJSON_AddStringToObject(knx_json, "multicastAddress", effective_knx->multicast_address.c_str()); cJSON_AddNumberToObject(knx_json, "individualAddress", effective_knx->individual_address); cJSON* serial_json = cJSON_CreateObject(); if (serial_json != nullptr) { cJSON_AddNumberToObject(serial_json, "uartPort", effective_knx->tp_uart.uart_port); cJSON_AddNumberToObject(serial_json, "txPin", effective_knx->tp_uart.tx_pin); cJSON_AddNumberToObject(serial_json, "rxPin", effective_knx->tp_uart.rx_pin); cJSON_AddNumberToObject(serial_json, "baudrate", effective_knx->tp_uart.baudrate); cJSON_AddItemToObject(knx_json, "tpUart", serial_json); } } cJSON_AddItemToObject(root, "knx", knx_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, GatewayBridgeStoredConfigToJson(bridge_config, modbus_config, knx_config, bacnet_server_config)}; } GatewayBridgeHttpResponse inventoryJson() const { cJSON* root = cJSON_CreateObject(); cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); cJSON_AddBoolToObject(root, "loaded", discovery_inventory_loaded); cJSON_AddNumberToObject(root, "count", static_cast(discovery_inventory.size())); cJSON* inventory = cJSON_CreateArray(); if (inventory != nullptr) { for (const auto& entry : discovery_inventory) { cJSON_AddItemToArray(inventory, DiscoveryEntryToCjson(entry.second)); } cJSON_AddItemToObject(root, "inventory", inventory); } return JsonOk(root); } GatewayBridgeHttpResponse effectiveModelJsonLocked() { cJSON* root = cJSON_CreateObject(); cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); cJSON_AddStringToObject(root, "name", channel.name.c_str()); cJSON* models = cJSON_CreateArray(); const auto effective_bacnet = effectiveBacnetObjectsLocked(); if (models != nullptr) { for (const auto& model : bridge_config.models) { cJSON* item = ToCjson(DaliValue(model.toJson())); if (item == nullptr) { continue; } cJSON_AddStringToObject(item, "displayName", model.displayName().c_str()); if (model.dali.kind == BridgeDaliTargetKind::shortAddress && model.dali.shortAddress.has_value() && ValidShortAddress(model.dali.shortAddress.value())) { const int short_address = model.dali.shortAddress.value(); if (const auto* entry = findDiscoveryEntryLocked(short_address)) { cJSON_AddBoolToObject(item, "discovered", true); cJSON_AddBoolToObject(item, "online", entry->online); cJSON_AddStringToObject(item, "inventoryState", DiscoveryStateString(entry->online)); cJSON_AddItemToObject(item, "discovery", DiscoveryEntryToCjson(*entry)); } else { cJSON_AddBoolToObject(item, "discovered", false); cJSON_AddBoolToObject(item, "online", false); cJSON_AddStringToObject(item, "inventoryState", "never_seen"); } } if (model.protocol == BridgeProtocolKind::bacnet && model.external.objectInstance.has_value()) { const auto binding = std::find_if( effective_bacnet.begin(), effective_bacnet.end(), [&model](const auto& item) { return item.modelID == model.id; }); const bool published = binding != effective_bacnet.end(); cJSON_AddBoolToObject(item, "bacnetPublished", published); if (published && model.dali.kind == BridgeDaliTargetKind::shortAddress && model.dali.shortAddress.has_value()) { const auto* entry = findDiscoveryEntryLocked(model.dali.shortAddress.value()); const bool out_of_service = entry != nullptr && !entry->online; cJSON_AddBoolToObject(item, "bacnetOutOfService", out_of_service); cJSON_AddStringToObject(item, "bacnetReliability", BacnetReliabilityToString(out_of_service ? kBacnetReliabilityCommunicationFailure : kBacnetReliabilityNoFaultDetected)); } } cJSON_AddItemToArray(models, item); } cJSON_AddItemToObject(root, "models", models); } return JsonOk(root); } GatewayBridgeHttpResponse effectiveModelJson() { LockGuard guard(lock); return effectiveModelJsonLocked(); } GatewayBridgeHttpResponse scanInventory(std::string_view body) { std::string mode = "referenced"; if (!body.empty()) { cJSON* root = cJSON_ParseWithLength(body.data(), body.size()); if (root == nullptr) { return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid scan JSON"); } if (const char* mode_value = JsonString(root, "mode")) { mode = mode_value; } else if (const char* mode_value = JsonString(root, "scanMode")) { mode = mode_value; } cJSON_Delete(root); } LockGuard guard(lock); std::vector addresses; if (mode == "all" || mode == "full") { addresses.reserve(kMaxDaliShortAddress + 1); for (int address = 0; address <= kMaxDaliShortAddress; ++address) { addresses.push_back(address); } mode = "all"; } else { addresses = referencedShortAddressesLocked(); mode = "referenced"; } size_t online_count = 0; size_t offline_count = 0; size_t unseen_count = 0; cJSON* results = cJSON_CreateArray(); for (const int address : addresses) { const auto* entry = updateDiscoveryEntryLocked(address, false); if (entry != nullptr) { if (entry->online) { ++online_count; } else { ++offline_count; } cJSON_AddItemToArray(results, DiscoveryEntryToCjson(*entry)); } else { ++unseen_count; cJSON_AddItemToArray(results, MissingDiscoveryEntryToCjson(channel.gateway_id, address)); } } const esp_err_t persist_err = saveDiscoveryInventoryLocked(); if (persist_err != ESP_OK) { cJSON_Delete(results); return ErrorResponse(persist_err, "failed to persist discovery inventory"); } discovery_inventory_loaded = discovery_inventory_loaded || !discovery_inventory.empty(); if (bacnet_started) { const esp_err_t err = syncBacnetServerLocked(); if (err != ESP_OK && err != ESP_ERR_NOT_FOUND) { cJSON_Delete(results); return ErrorResponse(err, "failed to refresh BACnet bridge after scan"); } bacnet_started = err == ESP_OK; } cJSON* root = cJSON_CreateObject(); cJSON_AddBoolToObject(root, "ok", true); cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); cJSON_AddStringToObject(root, "mode", mode.c_str()); cJSON_AddNumberToObject(root, "scanned", static_cast(addresses.size())); cJSON_AddNumberToObject(root, "onlineCount", static_cast(online_count)); cJSON_AddNumberToObject(root, "offlineCount", static_cast(offline_count)); cJSON_AddNumberToObject(root, "neverSeenCount", static_cast(unseen_count)); cJSON_AddItemToObject(root, "inventory", results); return JsonOk(root); } 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->describePoints()) { cJSON* item = cJSON_CreateObject(); if (item == nullptr) { continue; } if (!binding.model_id.empty()) { cJSON_AddStringToObject(item, "model", binding.model_id.c_str()); } cJSON_AddStringToObject(item, "space", GatewayModbusSpaceToString(binding.space)); cJSON_AddNumberToObject(item, "address", binding.address); cJSON_AddStringToObject(item, "id", binding.id.c_str()); cJSON_AddStringToObject(item, "name", binding.name.c_str()); cJSON_AddStringToObject(item, "access", GatewayModbusAccessToString(binding.access)); cJSON_AddBoolToObject(item, "generated", binding.generated); if (binding.generated) { cJSON_AddStringToObject(item, "generatedKind", GatewayModbusGeneratedKindToString(binding.generated_kind)); } if (binding.short_address >= 0) { cJSON_AddNumberToObject(item, "shortAddress", binding.short_address); } if (binding.bit_index.has_value()) { cJSON_AddNumberToObject(item, "bitIndex", binding.bit_index.value()); } if (!binding.diagnostic_snapshot.empty()) { cJSON_AddStringToObject(item, "diagnosticSnapshot", binding.diagnostic_snapshot.c_str()); } if (!binding.diagnostic_bool.empty()) { cJSON_AddStringToObject(item, "diagnosticBool", binding.diagnostic_bool.c_str()); } if (binding.diagnostic_device_type >= 0) { cJSON_AddNumberToObject(item, "diagnosticDeviceType", binding.diagnostic_device_type); } cJSON_AddItemToArray(bindings, item); } } cJSON_AddItemToObject(root, "bindings", bindings); return JsonOk(root); } GatewayBridgeHttpResponse knxBindingsJson() const { cJSON* root = cJSON_CreateObject(); cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); cJSON* bindings = cJSON_CreateArray(); if (bindings != nullptr && knx != nullptr) { for (const auto& binding : knx->describeDaliBindings()) { cJSON* item = cJSON_CreateObject(); if (item == nullptr) { continue; } cJSON_AddStringToObject(item, "address", binding.address.c_str()); cJSON_AddNumberToObject(item, "rawAddress", binding.group_address); cJSON_AddNumberToObject(item, "mainGroup", binding.main_group); cJSON_AddNumberToObject(item, "middleGroup", binding.middle_group); cJSON_AddNumberToObject(item, "subGroup", binding.sub_group); cJSON_AddStringToObject(item, "name", binding.name.c_str()); cJSON_AddStringToObject(item, "datapointType", binding.datapoint_type.c_str()); cJSON_AddStringToObject(item, "dataType", GatewayKnxDataTypeToString(binding.data_type)); cJSON_AddStringToObject(item, "targetKind", GatewayKnxTargetKindToString(binding.target.kind)); if (binding.target.address >= 0) { cJSON_AddNumberToObject(item, "targetAddress", binding.target.address); } cJSON_AddItemToArray(bindings, item); } } cJSON_AddItemToObject(root, "bindings", bindings); return JsonOk(root); } GatewayBridgeHttpResponse bacnetBindingsJson() { 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 : effectiveBacnetObjectsLocked()) { 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_AddStringToObject(item, "operation", bridgeOperationToString(binding.operation)); cJSON_AddStringToObject(item, "targetKind", bridgeDaliTargetKindToString(binding.target.kind)); if (const auto target_value = BridgeTargetValue(binding.target)) { cJSON_AddNumberToObject(item, "targetAddress", target_value.value()); } if (binding.target.rawAddress.has_value()) { cJSON_AddNumberToObject(item, "rawAddress", binding.target.rawAddress.value()); } if (binding.target.rawCommand.has_value()) { cJSON_AddNumberToObject(item, "rawCommand", binding.target.rawCommand.value()); } if (binding.target.kind == BridgeDaliTargetKind::shortAddress && binding.target.shortAddress.has_value()) { if (const auto* discovery = findDiscoveryEntryLocked(binding.target.shortAddress.value())) { const bool out_of_service = !discovery->online; cJSON_AddBoolToObject(item, "outOfService", out_of_service); cJSON_AddStringToObject(item, "reliability", BacnetReliabilityToString(out_of_service ? kBacnetReliabilityCommunicationFailure : kBacnetReliabilityNoFaultDetected)); cJSON_AddStringToObject(item, "inventoryState", DiscoveryStateString(discovery->online)); } else { cJSON_AddBoolToObject(item, "outOfService", false); cJSON_AddStringToObject(item, "reliability", BacnetReliabilityToString(kBacnetReliabilityNoFaultDetected)); cJSON_AddStringToObject(item, "inventoryState", "never_seen"); } } cJSON_AddItemToArray(bindings, item); } if (modbus != nullptr) { std::vector generated_points; generated_points.reserve(192); for (const auto& inventory_entry : discovery_inventory) { if (!ValidShortAddress(inventory_entry.first)) { continue; } generated_points.clear(); modbus->appendGeneratedPointsForShortAddress( static_cast(inventory_entry.first), &generated_points); for (const auto& point : generated_points) { if (!shouldPublishGeneratedBacnetPointLocked(point)) { continue; } const auto object_instance = generatedBacnetBinaryInputInstance(point.address); const auto* discovery = findDiscoveryEntryLocked(point.short_address); if (!object_instance.has_value() || discovery == nullptr) { continue; } cJSON* item = cJSON_CreateObject(); if (item == nullptr) { continue; } const auto binding = modbus->describePoint(point); cJSON_AddStringToObject(item, "model", binding.id.c_str()); cJSON_AddStringToObject(item, "name", binding.name.c_str()); cJSON_AddStringToObject(item, "objectType", "binaryInput"); cJSON_AddNumberToObject(item, "objectInstance", object_instance.value()); cJSON_AddStringToObject(item, "property", "presentValue"); cJSON_AddBoolToObject(item, "generated", true); cJSON_AddStringToObject(item, "generatedKind", GatewayModbusGeneratedKindToString(binding.generated_kind)); cJSON_AddNumberToObject(item, "shortAddress", binding.short_address); if (!binding.diagnostic_snapshot.empty()) { cJSON_AddStringToObject(item, "diagnosticSnapshot", binding.diagnostic_snapshot.c_str()); } if (!binding.diagnostic_bool.empty()) { cJSON_AddStringToObject(item, "diagnosticBool", binding.diagnostic_bool.c_str()); } if (binding.diagnostic_device_type >= 0) { cJSON_AddNumberToObject(item, "diagnosticDeviceType", binding.diagnostic_device_type); } const bool out_of_service = !discovery->online; cJSON_AddBoolToObject(item, "outOfService", out_of_service); cJSON_AddStringToObject(item, "reliability", BacnetReliabilityToString(out_of_service ? kBacnetReliabilityCommunicationFailure : kBacnetReliabilityNoFaultDetected)); cJSON_AddStringToObject(item, "inventoryState", DiscoveryStateString(discovery->online)); 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); } std::optional readGeneratedBoolPointLocked(const GatewayModbusPoint& point) { if (!point.generated || !ValidShortAddress(point.short_address)) { return std::nullopt; } const auto* discovery = findDiscoveryEntryLocked(point.short_address); const auto state = cache.daliAddressState(channel.gateway_id, static_cast(point.short_address)); switch (point.generated_kind) { case GatewayModbusGeneratedKind::kShortDiscovered: return discovery != nullptr; case GatewayModbusGeneratedKind::kShortOnline: return discovery != nullptr && discovery->online; case GatewayModbusGeneratedKind::kShortSupportsDt1: return discovery != nullptr && SnapshotHasDeviceType(discovery->discovery, 1); case GatewayModbusGeneratedKind::kShortSupportsDt4: return discovery != nullptr && SnapshotHasDeviceType(discovery->discovery, 4); case GatewayModbusGeneratedKind::kShortSupportsDt5: return discovery != nullptr && SnapshotHasDeviceType(discovery->discovery, 5); case GatewayModbusGeneratedKind::kShortSupportsDt6: return discovery != nullptr && SnapshotHasDeviceType(discovery->discovery, 6); case GatewayModbusGeneratedKind::kShortSupportsDt8: return discovery != nullptr && SnapshotHasDeviceType(discovery->discovery, 8); case GatewayModbusGeneratedKind::kShortGroupMaskKnown: return state.group_mask_known; case GatewayModbusGeneratedKind::kShortActualLevelKnown: return state.status.actual_level.has_value(); case GatewayModbusGeneratedKind::kShortSceneKnown: return state.status.scene_id.has_value(); case GatewayModbusGeneratedKind::kShortSettingsKnown: return state.settings.anyKnown(); case GatewayModbusGeneratedKind::kShortControlGearPresent: return readSnapshotBoolLocked(point.short_address, "base_status", "controlGearPresent"); case GatewayModbusGeneratedKind::kShortLampFailure: return readSnapshotBoolLocked(point.short_address, "base_status", "lampFailure"); case GatewayModbusGeneratedKind::kShortLampPowerOn: return readSnapshotBoolLocked(point.short_address, "base_status", "lampPowerOn"); case GatewayModbusGeneratedKind::kShortLimitError: return readSnapshotBoolLocked(point.short_address, "base_status", "limitError"); case GatewayModbusGeneratedKind::kShortFadingCompleted: return readSnapshotBoolLocked(point.short_address, "base_status", "fadingCompleted"); case GatewayModbusGeneratedKind::kShortResetState: return readSnapshotBoolLocked(point.short_address, "base_status", "resetState"); case GatewayModbusGeneratedKind::kShortMissingShortAddress: return readSnapshotBoolLocked(point.short_address, "base_status", "missingShortAddress"); case GatewayModbusGeneratedKind::kShortPowerSupplyFault: return readSnapshotBoolLocked(point.short_address, "base_status", "powerSupplyFault"); case GatewayModbusGeneratedKind::kShortDiagnosticBit: return readDiagnosticBoolPointLocked(point); default: return std::nullopt; } } std::optional readGeneratedRegisterPointLocked(const GatewayModbusPoint& point) { if (!point.generated || !ValidShortAddress(point.short_address)) { return std::nullopt; } const auto* discovery = findDiscoveryEntryLocked(point.short_address); const auto state = cache.daliAddressState(channel.gateway_id, static_cast(point.short_address)); switch (point.generated_kind) { case GatewayModbusGeneratedKind::kShortInventoryState: if (discovery == nullptr) { return 0; } return discovery->online ? 2 : 1; case GatewayModbusGeneratedKind::kShortPrimaryType: { if (discovery == nullptr) { return kModbusUnknownRegister; } const auto primary = discovery->discovery.ints.find("primaryType"); return primary == discovery->discovery.ints.end() ? kModbusUnknownRegister : static_cast(primary->second); } case GatewayModbusGeneratedKind::kShortTypeMask: return discovery == nullptr ? kModbusUnknownRegister : DeviceTypeMask(discovery->discovery); case GatewayModbusGeneratedKind::kShortBrightness: case GatewayModbusGeneratedKind::kShortActualLevel: return state.status.actual_level.has_value() ? static_cast(state.status.actual_level.value()) : kModbusUnknownRegister; case GatewayModbusGeneratedKind::kShortSceneId: return state.status.scene_id.has_value() ? static_cast(state.status.scene_id.value()) : kModbusUnknownRegister; case GatewayModbusGeneratedKind::kShortRawStatus: { const auto snapshot = diagnosticSnapshotLocked(point.short_address, "base_status"); if (snapshot.has_value()) { const auto raw_status = SnapshotIntValue(snapshot.value(), "rawStatus"); return raw_status.has_value() ? static_cast(raw_status.value() & 0xFF) : kModbusUnknownRegister; } return kModbusUnknownRegister; } case GatewayModbusGeneratedKind::kShortGroupMask: return state.group_mask_known ? state.group_mask : kModbusUnknownRegister; case GatewayModbusGeneratedKind::kShortPowerOnLevel: return state.settings.power_on_level.has_value() ? static_cast(state.settings.power_on_level.value()) : kModbusUnknownRegister; case GatewayModbusGeneratedKind::kShortSystemFailureLevel: return state.settings.system_failure_level.has_value() ? static_cast(state.settings.system_failure_level.value()) : kModbusUnknownRegister; case GatewayModbusGeneratedKind::kShortMinLevel: return state.settings.min_level.has_value() ? static_cast(state.settings.min_level.value()) : kModbusUnknownRegister; case GatewayModbusGeneratedKind::kShortMaxLevel: return state.settings.max_level.has_value() ? static_cast(state.settings.max_level.value()) : kModbusUnknownRegister; case GatewayModbusGeneratedKind::kShortFadeTime: return state.settings.fade_time.has_value() ? static_cast(state.settings.fade_time.value()) : kModbusUnknownRegister; case GatewayModbusGeneratedKind::kShortFadeRate: return state.settings.fade_rate.has_value() ? static_cast(state.settings.fade_rate.value()) : kModbusUnknownRegister; case GatewayModbusGeneratedKind::kShortColorTemperature: return kModbusUnknownRegister; default: return std::nullopt; } } bool writeGeneratedCoilPointLocked(const GatewayModbusPoint& point, bool value) { if (!point.generated || !ValidShortAddress(point.short_address)) { return false; } if (!value) { return true; } const uint8_t raw_command_address = RawCommandAddressFromDec(point.short_address); bool sent = false; uint8_t mirrored_command = 0; switch (point.generated_kind) { case GatewayModbusGeneratedKind::kShortOn: case GatewayModbusGeneratedKind::kShortRecallMax: sent = domain.on(channel.gateway_id, point.short_address); mirrored_command = DALI_CMD_RECALL_MAX; break; case GatewayModbusGeneratedKind::kShortOff: sent = domain.off(channel.gateway_id, point.short_address); mirrored_command = DALI_CMD_OFF; break; case GatewayModbusGeneratedKind::kShortRecallMin: sent = domain.sendRaw(channel.gateway_id, raw_command_address, DALI_CMD_RECALL_MIN); mirrored_command = DALI_CMD_RECALL_MIN; break; default: return false; } if (sent) { cache.mirrorDaliCommand(channel.gateway_id, raw_command_address, mirrored_command); } return sent; } bool writeGeneratedRegisterPointLocked(const GatewayModbusPoint& point, uint16_t value) { if (!point.generated || !ValidShortAddress(point.short_address)) { return false; } switch (point.generated_kind) { case GatewayModbusGeneratedKind::kShortBrightness: if (value > 254) { return false; } if (domain.setBright(channel.gateway_id, point.short_address, value)) { cache.mirrorDaliCommand(channel.gateway_id, RawArcAddressFromDec(point.short_address), static_cast(value)); return true; } return false; case GatewayModbusGeneratedKind::kShortColorTemperature: return domain.setColTemp(channel.gateway_id, point.short_address, value); case GatewayModbusGeneratedKind::kShortGroupMask: if (domain.applyGroupMask(channel.gateway_id, point.short_address, value)) { cache.setDaliGroupMask(channel.gateway_id, static_cast(point.short_address), value); return true; } return false; case GatewayModbusGeneratedKind::kShortPowerOnLevel: case GatewayModbusGeneratedKind::kShortSystemFailureLevel: case GatewayModbusGeneratedKind::kShortMinLevel: case GatewayModbusGeneratedKind::kShortMaxLevel: case GatewayModbusGeneratedKind::kShortFadeTime: case GatewayModbusGeneratedKind::kShortFadeRate: { if (value > 255) { return false; } auto current = cache.daliAddressState(channel.gateway_id, static_cast(point.short_address)).settings; switch (point.generated_kind) { case GatewayModbusGeneratedKind::kShortPowerOnLevel: current.power_on_level = static_cast(value); break; case GatewayModbusGeneratedKind::kShortSystemFailureLevel: current.system_failure_level = static_cast(value); break; case GatewayModbusGeneratedKind::kShortMinLevel: current.min_level = static_cast(value); break; case GatewayModbusGeneratedKind::kShortMaxLevel: current.max_level = static_cast(value); break; case GatewayModbusGeneratedKind::kShortFadeTime: current.fade_time = static_cast(value); break; case GatewayModbusGeneratedKind::kShortFadeRate: current.fade_rate = static_cast(value); break; default: break; } DaliAddressSettingsSnapshot domain_settings; domain_settings.power_on_level = current.power_on_level; domain_settings.system_failure_level = current.system_failure_level; domain_settings.min_level = current.min_level; domain_settings.max_level = current.max_level; domain_settings.fade_time = current.fade_time; domain_settings.fade_rate = current.fade_rate; if (domain.applyAddressSettings(channel.gateway_id, point.short_address, domain_settings)) { cache.setDaliSettings(channel.gateway_id, static_cast(point.short_address), current); return true; } return false; } default: return false; } } std::optional readModbusBoolPoint(GatewayModbusSpace space, uint16_t address) { LockGuard guard(lock); if (modbus == nullptr) { return std::nullopt; } const auto point = modbus->findPoint(space, address); if (!point.has_value()) { return std::nullopt; } if (point->generated) { return readGeneratedBoolPointLocked(point.value()).value_or(false); } const DaliBridgeResult result = modbus->readModelPoint(point.value()); if (!result.ok || !result.data.has_value()) { return std::nullopt; } if (point->bit_index.has_value() && point->bit_index.value() >= 0 && point->bit_index.value() < 16) { return (result.data.value() & (1 << point->bit_index.value())) != 0; } return result.data.value() != 0; } std::optional readModbusRegisterPoint(GatewayModbusSpace space, uint16_t address) { LockGuard guard(lock); if (modbus == nullptr) { return std::nullopt; } const auto point = modbus->findPoint(space, address); if (!point.has_value()) { return std::nullopt; } if (point->generated) { return readGeneratedRegisterPointLocked(point.value()).value_or(kModbusUnknownRegister); } const DaliBridgeResult result = modbus->readModelPoint(point.value()); if (!result.ok || !result.data.has_value()) { return std::nullopt; } return static_cast(result.data.value() & 0xFFFF); } bool writeModbusCoilPoint(uint16_t address, bool value) { LockGuard guard(lock); if (modbus == nullptr) { return false; } const auto point = modbus->findPoint(GatewayModbusSpace::kCoil, address); if (!point.has_value()) { return false; } if (point->generated) { return writeGeneratedCoilPointLocked(point.value(), value); } const DaliBridgeResult result = modbus->writeCoilPoint(point.value(), value); return result.ok; } bool writeModbusRegisterPoint(uint16_t address, uint16_t value) { LockGuard guard(lock); if (modbus == nullptr) { return false; } const auto point = modbus->findPoint(GatewayModbusSpace::kHoldingRegister, address); if (!point.has_value()) { return false; } if (point->generated) { return writeGeneratedRegisterPointLocked(point.value(), value); } const DaliBridgeResult result = modbus->writeRegisterPoint(point.value(), value); return result.ok; } std::optional activeModbusConfigLocked() const { if (modbus_config.has_value()) { return modbus_config; } return service_config.default_modbus_config; } std::optional activeModbusConfig() const { LockGuard guard(lock); return activeModbusConfigLocked(); } bool isReservedUartLocked(int uart_port) const { return std::find(service_config.reserved_uart_ports.begin(), service_config.reserved_uart_ports.end(), uart_port) != service_config.reserved_uart_ports.end(); } esp_err_t validateSerialModbusConfigLocked( const GatewayModbusConfig& config, const std::optional& candidate_knx, std::string* error_message = nullptr) const { const int uart_port = config.serial.uart_port; if (uart_port < 0 || uart_port > 2) { if (error_message != nullptr) { *error_message = "Modbus serial UART port must be 0, 1, or 2"; } return ESP_ERR_INVALID_ARG; } if (uart_port == 0 && !service_config.allow_modbus_uart0) { if (error_message != nullptr) { *error_message = "Modbus serial on UART0 requires moving the ESP-IDF console and UART0 control off UART0"; } return ESP_ERR_INVALID_STATE; } if (isReservedUartLocked(uart_port)) { if (error_message != nullptr) { *error_message = "Modbus serial UART" + std::to_string(uart_port) + " is already reserved by a DALI serial PHY"; } return ESP_ERR_INVALID_STATE; } if (service_config.knx_enabled && candidate_knx.has_value() && candidate_knx->ip_router_enabled && candidate_knx->tp_uart.uart_port == uart_port) { if (error_message != nullptr) { *error_message = "Modbus serial UART" + std::to_string(uart_port) + " conflicts with KNX TP-UART; choose another free UART for RS485"; } return ESP_ERR_INVALID_STATE; } return ESP_OK; } esp_err_t validateKnxConfigLocked(const GatewayKnxConfig& config, const std::optional& candidate_modbus, std::string* error_message = nullptr) const { if (!config.ip_router_enabled) { return ESP_OK; } const int uart_port = config.tp_uart.uart_port; if (uart_port < 0 || uart_port > 2) { if (error_message != nullptr) { *error_message = "KNX TP-UART port must be 0, 1, or 2"; } return ESP_ERR_INVALID_ARG; } if (uart_port == 0 && !service_config.allow_knx_uart0) { if (error_message != nullptr) { *error_message = "KNX TP-UART on UART0 requires moving the ESP-IDF console and UART0 control off UART0"; } return ESP_ERR_INVALID_STATE; } if (isReservedUartLocked(uart_port)) { if (error_message != nullptr) { *error_message = "KNX TP-UART UART" + std::to_string(uart_port) + " is already reserved by a DALI serial PHY"; } return ESP_ERR_INVALID_STATE; } if (service_config.modbus_enabled && candidate_modbus.has_value() && GatewayModbusTransportIsSerial(candidate_modbus->transport) && candidate_modbus->serial.uart_port == uart_port) { if (error_message != nullptr) { *error_message = "KNX TP-UART UART" + std::to_string(uart_port) + " conflicts with Modbus serial UART"; } return ESP_ERR_INVALID_STATE; } return ESP_OK; } esp_err_t validateStoredBridgeConfigLocked(const GatewayBridgeStoredConfig& config) { const auto candidate_modbus = config.modbus.has_value() ? config.modbus : service_config.default_modbus_config; const auto candidate_knx = config.knx.has_value() ? config.knx : service_config.default_knx_config; std::string validation_error; if (candidate_modbus.has_value() && GatewayModbusTransportIsSerial(candidate_modbus->transport)) { const esp_err_t err = validateSerialModbusConfigLocked( candidate_modbus.value(), candidate_knx, &validation_error); if (err != ESP_OK) { modbus_last_error = validation_error; return err; } } if (candidate_knx.has_value()) { const esp_err_t err = validateKnxConfigLocked(candidate_knx.value(), candidate_modbus, &validation_error); if (err != ESP_OK) { knx_last_error = validation_error; return err; } } modbus_last_error.clear(); knx_last_error.clear(); return ESP_OK; } esp_err_t saveModbusConfig(const GatewayModbusConfig& config) { LockGuard guard(lock); if (GatewayModbusTransportIsSerial(config.transport)) { std::string validation_error; const esp_err_t validation_err = validateSerialModbusConfigLocked( config, activeKnxConfigLocked(), &validation_error); if (validation_err != ESP_OK) { modbus_last_error = validation_error; return validation_err; } } BridgeProvisioningStore store(bridgeNamespace()); const esp_err_t err = store.saveObject( kBridgeConfigKey, GatewayBridgeStoredConfigToValue(bridge_config, config, knx_config, bacnet_server_config)); if (err != ESP_OK) { return err; } modbus_config = config; bridge_config_loaded = true; if (modbus != nullptr) { modbus->setConfig(config); } modbus_last_error.clear(); return ESP_OK; } std::optional activeKnxConfigLocked() const { if (knx_config.has_value()) { return knx_config; } return service_config.default_knx_config; } esp_err_t saveKnxConfig(const GatewayKnxConfig& config, std::set* used_ports = nullptr, std::set* used_uarts = nullptr) { LockGuard guard(lock); std::string validation_error; const esp_err_t validation_err = validateKnxConfigLocked( config, activeModbusConfigLocked(), &validation_error); if (validation_err != ESP_OK) { knx_last_error = validation_error; return validation_err; } const bool restart_router = knx_started || (knx_router != nullptr && knx_router->started()); if (restart_router && config.ip_router_enabled && used_ports != nullptr && used_ports->find(config.udp_port) != used_ports->end()) { knx_last_error = "duplicate KNXnet/IP UDP port " + std::to_string(config.udp_port); return ESP_ERR_INVALID_STATE; } if (restart_router && config.ip_router_enabled && used_uarts != nullptr && used_uarts->find(config.tp_uart.uart_port) != used_uarts->end()) { knx_last_error = "KNX TP-UART UART" + std::to_string(config.tp_uart.uart_port) + " is already used by another runtime"; return ESP_ERR_INVALID_STATE; } if (restart_router && knx_router != nullptr) { knx_router->stop(); knx_started = false; } BridgeProvisioningStore store(bridgeNamespace()); const esp_err_t err = store.saveObject( kBridgeConfigKey, GatewayBridgeStoredConfigToValue(bridge_config, modbus_config, config, bacnet_server_config)); if (err != ESP_OK) { return err; } knx_config = config; bridge_config_loaded = true; if (knx != nullptr) { knx->setConfig(config); } if (knx_router != nullptr) { knx_router->setConfig(config); } if (restart_router) { return startKnx(used_ports, used_uarts); } knx_last_error.clear(); return ESP_OK; } std::vector processModbusPdu(const GatewayModbusConfig& config, uint8_t unit_id, const std::vector& pdu) { if (pdu.empty()) { return {}; } if (config.unit_id != 0 && unit_id != config.unit_id) { return ModbusExceptionPdu(pdu[0], 0x0B); } if ((pdu[0] == 0x01 || pdu[0] == 0x02) && pdu.size() == 5) { const auto space = GatewayModbusReadSpaceForFunction(pdu[0]); const uint16_t start_address = ReadBe16(&pdu[1]); const uint16_t quantity = ReadBe16(&pdu[3]); if (!space.has_value() || quantity == 0 || quantity > kGatewayModbusMaxReadBits) { return ModbusExceptionPdu(pdu[0], 0x03); } const uint8_t byte_count = static_cast((quantity + 7U) / 8U); std::vector response(2 + byte_count, 0); response[0] = pdu[0]; response[1] = byte_count; for (uint16_t index = 0; index < quantity; ++index) { const auto human_address = static_cast( GatewayModbusHumanAddressFromWire(space.value(), start_address + index)); const auto value = readModbusBoolPoint(space.value(), human_address); if (!value.has_value()) { return ModbusExceptionPdu(pdu[0], 0x02); } if (value.value()) { response[2 + (index / 8)] |= static_cast(1U << (index % 8)); } } return response; } if ((pdu[0] == 0x03 || pdu[0] == 0x04) && pdu.size() == 5) { const auto space = GatewayModbusReadSpaceForFunction(pdu[0]); const uint16_t start_address = ReadBe16(&pdu[1]); const uint16_t quantity = ReadBe16(&pdu[3]); if (!space.has_value() || quantity == 0 || quantity > kGatewayModbusMaxReadRegisters) { return ModbusExceptionPdu(pdu[0], 0x03); } std::vector response(2 + quantity * 2); response[0] = pdu[0]; response[1] = static_cast(quantity * 2); for (uint16_t index = 0; index < quantity; ++index) { const auto human_address = static_cast( GatewayModbusHumanAddressFromWire(space.value(), start_address + index)); const auto value = readModbusRegisterPoint(space.value(), human_address); if (!value.has_value()) { return ModbusExceptionPdu(pdu[0], 0x02); } WriteBe16(&response[2 + index * 2], value.value()); } return response; } if (pdu[0] == 0x05 && pdu.size() == 5) { const uint16_t wire_address = ReadBe16(&pdu[1]); const uint16_t raw_value = ReadBe16(&pdu[3]); if (raw_value != 0x0000 && raw_value != 0xFF00) { return ModbusExceptionPdu(pdu[0], 0x03); } const auto coil = static_cast(GatewayModbusHumanAddressFromWire( GatewayModbusSpace::kCoil, wire_address)); if (!writeModbusCoilPoint(coil, raw_value == 0xFF00)) { return ModbusExceptionPdu(pdu[0], 0x04); } return pdu; } if (pdu[0] == 0x06 && pdu.size() == 5) { const uint16_t wire_register = ReadBe16(&pdu[1]); const uint16_t value = ReadBe16(&pdu[3]); const auto holding_register = static_cast(GatewayModbusHumanAddressFromWire( GatewayModbusSpace::kHoldingRegister, wire_register)); if (!writeModbusRegisterPoint(holding_register, value)) { return ModbusExceptionPdu(pdu[0], 0x04); } return pdu; } if (pdu[0] == 0x0F && pdu.size() >= 6) { const uint16_t start_address = ReadBe16(&pdu[1]); const uint16_t quantity = ReadBe16(&pdu[3]); const uint8_t byte_count = pdu[5]; if (quantity == 0 || quantity > kGatewayModbusMaxWriteBits || pdu.size() != static_cast(6 + byte_count) || byte_count != static_cast((quantity + 7U) / 8U)) { return ModbusExceptionPdu(pdu[0], 0x03); } for (uint16_t index = 0; index < quantity; ++index) { const bool value = (pdu[6 + (index / 8)] & (1U << (index % 8))) != 0; const auto coil = static_cast(GatewayModbusHumanAddressFromWire( GatewayModbusSpace::kCoil, start_address + index)); if (!writeModbusCoilPoint(coil, value)) { return ModbusExceptionPdu(pdu[0], 0x04); } } std::vector response(5); response[0] = pdu[0]; WriteBe16(&response[1], start_address); WriteBe16(&response[3], quantity); return response; } 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 (quantity == 0 || quantity > kGatewayModbusMaxWriteRegisters || pdu.size() != static_cast(6 + byte_count) || byte_count != quantity * 2) { return ModbusExceptionPdu(pdu[0], 0x03); } for (uint16_t index = 0; index < quantity; ++index) { const size_t offset = 6 + (index * 2); const uint16_t value = ReadBe16(&pdu[offset]); const auto holding_register = static_cast(GatewayModbusHumanAddressFromWire( GatewayModbusSpace::kHoldingRegister, start_register + index)); if (!writeModbusRegisterPoint(holding_register, value)) { return ModbusExceptionPdu(pdu[0], 0x04); } } std::vector response(5); response[0] = pdu[0]; WriteBe16(&response[1], start_register); WriteBe16(&response[3], quantity); return response; } return ModbusExceptionPdu(pdu[0], 0x01); } esp_err_t startModbus(std::set* used_ports = nullptr, std::set* used_uarts = nullptr) { LockGuard guard(lock); if (!service_config.modbus_enabled) { return ESP_ERR_NOT_SUPPORTED; } if (modbus_started || modbus_task_handle != nullptr) { return ESP_OK; } const auto config = activeModbusConfigLocked(); if (!config.has_value()) { return ESP_ERR_NOT_FOUND; } if (GatewayModbusTransportIsSerial(config->transport)) { std::string validation_error; const esp_err_t serial_err = validateSerialModbusConfigLocked( config.value(), activeKnxConfigLocked(), &validation_error); if (serial_err != ESP_OK) { modbus_last_error = validation_error; return serial_err; } if (used_uarts != nullptr) { const int uart_port = config->serial.uart_port; if (used_uarts->find(uart_port) != used_uarts->end()) { modbus_last_error = "Modbus serial UART" + std::to_string(uart_port) + " is already used by another runtime"; ESP_LOGW(kTag, "gateway=%u skips duplicate Modbus serial UART%d", channel.gateway_id, uart_port); return ESP_ERR_INVALID_STATE; } used_uarts->insert(uart_port); } } const uint16_t port = config->port == 0 ? kGatewayModbusDefaultTcpPort : config->port; if (GatewayModbusTransportIsTcp(config->transport) && used_ports != nullptr) { if (used_ports->find(port) != used_ports->end()) { modbus_last_error = "duplicate Modbus TCP port " + std::to_string(port); ESP_LOGW(kTag, "gateway=%u skips duplicate Modbus TCP port %u", channel.gateway_id, port); return ESP_ERR_INVALID_STATE; } used_ports->insert(port); } modbus_stop_requested = false; modbus_restart_requested = false; modbus_last_error.clear(); const char* task_name = GatewayModbusTransportIsTcp(config->transport) ? "gw_modbus_tcp" : (GatewayModbusTransportIsAscii(config->transport) ? "gw_modbus_ascii" : "gw_modbus_rtu"); const BaseType_t created = xTaskCreate(&ChannelRuntime::ModbusTaskEntry, task_name, 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; } esp_err_t stopModbus() { LockGuard guard(lock); if (!modbus_started && modbus_task_handle == nullptr) { return ESP_OK; } modbus_stop_requested = true; modbus_restart_requested = false; if (modbus_client_sock >= 0) { shutdown(modbus_client_sock, SHUT_RDWR); } if (modbus_listen_sock >= 0) { shutdown(modbus_listen_sock, SHUT_RDWR); } return ESP_OK; } void modbusTaskLoop() { const auto config = activeModbusConfig(); if (!config.has_value()) { modbus_last_error = "missing Modbus config"; finishModbusTask(); return; } if (GatewayModbusTransportIsTcp(config->transport)) { modbusTcpTaskLoop(config.value()); } else if (GatewayModbusTransportIsSerial(config->transport)) { modbusSerialTaskLoop(config.value()); } else { modbus_last_error = "unsupported Modbus transport"; ESP_LOGE(kTag, "gateway=%u unsupported Modbus transport %s", channel.gateway_id, config->transport.c_str()); } finishModbusTask(); } void finishModbusTask() { const bool restart = modbus_restart_requested.exchange(false); { LockGuard guard(lock); modbus_started = false; modbus_task_handle = nullptr; modbus_stop_requested = false; modbus_listen_sock = -1; modbus_client_sock = -1; modbus_uart_port = -1; } if (restart) { startModbus(); } vTaskDelete(nullptr); } void modbusTcpTaskLoop(const GatewayModbusConfig& config) { const uint16_t port = config.port != 0 ? config.port : kGatewayModbusDefaultTcpPort; 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_last_error = "failed to create Modbus TCP socket"; return; } modbus_listen_sock = listen_sock; int reuse = 1; setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); timeval timeout{}; timeout.tv_sec = 1; timeout.tv_usec = 0; setsockopt(listen_sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); 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); modbus_last_error = "failed to bind Modbus TCP port"; close(listen_sock); return; } ESP_LOGI(kTag, "gateway=%u Modbus TCP listening on port %u", channel.gateway_id, port); while (!modbus_stop_requested) { 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; } setsockopt(client_sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); modbus_client_sock = client_sock; handleModbusClient(config, client_sock); modbus_client_sock = -1; close(client_sock); } close(listen_sock); modbus_listen_sock = -1; } void handleModbusClient(const GatewayModbusConfig& config, int client_sock) { uint8_t header[7] = {}; while (!modbus_stop_requested && 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 > kGatewayModbusMaxPduBytes) { break; } std::vector pdu(length - 1); if (!RecvAll(client_sock, pdu.data(), pdu.size()) || pdu.empty()) { break; } SendModbusFrame(client_sock, header, processModbusPdu(config, header[6], pdu)); } } esp_err_t installSerialModbus(const GatewayModbusConfig& config) { const auto uart_port = static_cast(config.serial.uart_port); uart_config_t uart_config{}; uart_config.baud_rate = static_cast(config.serial.baudrate); uart_config.data_bits = UartWordLength(config.serial.data_bits); uart_config.parity = UartParity(config.serial.parity); uart_config.stop_bits = UartStopBits(config.serial.stop_bits); uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; uart_config.source_clk = UART_SCLK_DEFAULT; esp_err_t err = uart_param_config(uart_port, &uart_config); if (err != ESP_OK) { return err; } const int rts_pin = config.serial.rs485.enabled ? config.serial.rs485.de_pin : UART_PIN_NO_CHANGE; err = uart_set_pin(uart_port, config.serial.tx_pin, config.serial.rx_pin, rts_pin, UART_PIN_NO_CHANGE); if (err != ESP_OK) { return err; } err = uart_driver_install(uart_port, static_cast(config.serial.rx_buffer_size), static_cast(config.serial.tx_buffer_size), 0, nullptr, 0); if (err != ESP_OK) { return err; } if (config.serial.rs485.enabled) { err = uart_set_mode(uart_port, UART_MODE_RS485_HALF_DUPLEX); if (err != ESP_OK) { uart_driver_delete(uart_port); return err; } } modbus_uart_port = config.serial.uart_port; return ESP_OK; } void modbusSerialTaskLoop(const GatewayModbusConfig& config) { const esp_err_t err = installSerialModbus(config); if (err != ESP_OK) { modbus_last_error = "failed to install Modbus serial UART"; ESP_LOGE(kTag, "gateway=%u failed to install Modbus serial UART%d: %s", channel.gateway_id, config.serial.uart_port, esp_err_to_name(err)); return; } ESP_LOGI(kTag, "gateway=%u Modbus %s listening on UART%d baud=%lu", channel.gateway_id, GatewayModbusTransportIsAscii(config.transport) ? "ASCII" : "RTU", config.serial.uart_port, static_cast(config.serial.baudrate)); if (GatewayModbusTransportIsAscii(config.transport)) { modbusAsciiTaskLoop(config); } else { modbusRtuTaskLoop(config); } uart_driver_delete(static_cast(config.serial.uart_port)); } void modbusRtuTaskLoop(const GatewayModbusConfig& config) { const auto uart_port = static_cast(config.serial.uart_port); std::vector frame; std::array read_buffer{}; const TickType_t timeout = pdMS_TO_TICKS(config.serial.response_timeout_ms); while (!modbus_stop_requested) { const int read_len = uart_read_bytes(uart_port, read_buffer.data(), read_buffer.size(), timeout); if (read_len > 0) { frame.insert(frame.end(), read_buffer.begin(), read_buffer.begin() + read_len); if (!frame.empty() && frame.front() == '@' && std::find(frame.begin(), frame.end(), '\n') != frame.end()) { const std::string line(frame.begin(), frame.end()); handleModbusManagementLine(config.serial.uart_port, line); frame.clear(); } else if (frame.size() > 512) { frame.clear(); } continue; } if (frame.empty() || frame.front() == '@') { continue; } handleModbusRtuFrame(config, frame); frame.clear(); } } void handleModbusRtuFrame(const GatewayModbusConfig& config, const std::vector& frame) { if (frame.size() < 4) { return; } const uint16_t received_crc = static_cast(frame[frame.size() - 2] | (frame[frame.size() - 1] << 8)); if (ModbusCrc16(frame.data(), frame.size() - 2) != received_crc) { return; } const uint8_t unit_id = frame[0]; if (unit_id == 0) { return; } const std::vector pdu(frame.begin() + 1, frame.end() - 2); const auto response_pdu = processModbusPdu(config, unit_id, pdu); if (response_pdu.empty()) { return; } std::vector response; response.reserve(1 + response_pdu.size() + 2); response.push_back(unit_id); response.insert(response.end(), response_pdu.begin(), response_pdu.end()); const uint16_t crc = ModbusCrc16(response.data(), response.size()); response.push_back(static_cast(crc & 0xFF)); response.push_back(static_cast((crc >> 8) & 0xFF)); uart_write_bytes(static_cast(config.serial.uart_port), response.data(), response.size()); } void modbusAsciiTaskLoop(const GatewayModbusConfig& config) { const auto uart_port = static_cast(config.serial.uart_port); std::string line; std::array read_buffer{}; const TickType_t timeout = pdMS_TO_TICKS(config.serial.response_timeout_ms); while (!modbus_stop_requested) { const int read_len = uart_read_bytes(uart_port, read_buffer.data(), read_buffer.size(), timeout); if (read_len <= 0) { continue; } for (int i = 0; i < read_len; ++i) { const char ch = static_cast(read_buffer[i]); if (line.empty()) { if (ch != ':' && ch != '@') { continue; } } line.push_back(ch); if (ch == '\n') { if (LineStartsWith(line, kModbusManagementPrefix)) { handleModbusManagementLine(config.serial.uart_port, line); } else if (!line.empty() && line.front() == ':') { handleModbusAsciiFrame(config, line); } line.clear(); } else if (line.size() > 1024) { line.clear(); } } } } void handleModbusAsciiFrame(const GatewayModbusConfig& config, std::string_view line) { const auto decoded = DecodeModbusAsciiLine(line); if (!decoded.has_value() || decoded->size() < 4) { return; } const uint8_t unit_id = decoded->front(); if (unit_id == 0) { return; } const std::vector pdu(decoded->begin() + 1, decoded->end() - 1); const auto response_pdu = processModbusPdu(config, unit_id, pdu); if (response_pdu.empty()) { return; } std::vector response; response.reserve(1 + response_pdu.size() + 1); response.push_back(unit_id); response.insert(response.end(), response_pdu.begin(), response_pdu.end()); response.push_back(ModbusAsciiLrc(response.data(), response.size())); const std::string encoded = EncodeModbusAsciiLine(response); uart_write_bytes(static_cast(config.serial.uart_port), encoded.data(), encoded.size()); } void handleModbusManagementLine(int uart_port, std::string_view line) { while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) { line.remove_suffix(1); } if (!LineStartsWith(line, kModbusManagementPrefix)) { return; } line.remove_prefix(std::char_traits::length(kModbusManagementPrefix)); while (!line.empty() && line.front() == ' ') { line.remove_prefix(1); } cJSON* root = line.empty() ? cJSON_CreateObject() : cJSON_ParseWithLength(line.data(), line.size()); if (root == nullptr || !cJSON_IsObject(root)) { cJSON_Delete(root); writeModbusManagementResponse(uart_port, false, "unknown", "invalid JSON"); return; } const auto gateway_id = JsonGatewayId(root); if (gateway_id.has_value() && gateway_id.value() != channel.gateway_id) { cJSON_Delete(root); writeModbusManagementResponse(uart_port, false, "unknown", "gateway id mismatch"); return; } const char* action_raw = JsonString(root, "action"); const std::string action = action_raw == nullptr ? "modbus_status" : action_raw; if (action == "modbus_config") { const cJSON* modbus_node = cJSON_GetObjectItemCaseSensitive(root, "modbus"); if (modbus_node == nullptr) { modbus_node = root; } const DaliValue modbus_value = FromCjson(modbus_node); const auto parsed = GatewayModbusConfigFromValue(&modbus_value); if (!parsed.has_value()) { cJSON_Delete(root); writeModbusManagementResponse(uart_port, false, action.c_str(), "invalid modbus config"); return; } const esp_err_t err = saveModbusConfig(parsed.value()); cJSON_Delete(root); if (err != ESP_OK) { writeModbusManagementResponse( uart_port, false, action.c_str(), modbus_last_error.empty() ? esp_err_to_name(err) : modbus_last_error.c_str()); return; } writeModbusManagementResponse(uart_port, true, action.c_str(), nullptr); modbus_restart_requested = true; modbus_stop_requested = true; return; } if (action == "modbus_stop") { cJSON_Delete(root); writeModbusManagementResponse(uart_port, true, action.c_str(), nullptr); modbus_stop_requested = true; return; } if (action == "modbus_start" || action == "modbus_status") { cJSON_Delete(root); writeModbusManagementResponse(uart_port, true, action.c_str(), nullptr); return; } cJSON_Delete(root); writeModbusManagementResponse(uart_port, false, action.c_str(), "unknown action"); } void writeModbusManagementResponse(int uart_port, const bool ok, const char* action, const char* error) const { cJSON* root = cJSON_CreateObject(); cJSON_AddBoolToObject(root, "ok", ok); cJSON_AddNumberToObject(root, "gw", channel.gateway_id); cJSON_AddStringToObject(root, "action", action == nullptr ? "unknown" : action); if (const auto config = activeModbusConfig()) { cJSON_AddStringToObject(root, "transport", config->transport.c_str()); cJSON_AddNumberToObject(root, "unitID", config->unit_id); if (GatewayModbusTransportIsSerial(config->transport)) { cJSON_AddNumberToObject(root, "uartPort", config->serial.uart_port); cJSON_AddNumberToObject(root, "baudrate", config->serial.baudrate); } else { cJSON_AddNumberToObject(root, "port", config->port); } } if (error != nullptr) { cJSON_AddStringToObject(root, "error", error); } const std::string body = "@DALIGW " + PrintJson(root) + "\n"; cJSON_Delete(root); uart_write_bytes(static_cast(uart_port), body.data(), body.size()); } }; GatewayBridgeService::GatewayBridgeService(DaliDomainService& dali_domain, GatewayCache& cache, GatewayBridgeServiceConfig config) : dali_domain_(dali_domain), cache_(cache), 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_, cache_, 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)); } std::set used_serial_uarts; if (config_.modbus_enabled && config_.modbus_startup_enabled) { std::set used_modbus_ports; for (const auto& runtime : runtimes_) { const esp_err_t err = runtime->startModbus(&used_modbus_ports, &used_serial_uarts); 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_.knx_enabled && config_.knx_startup_enabled) { std::set used_knx_ports; for (const auto& runtime : runtimes_) { const esp_err_t err = runtime->startKnx(&used_knx_ports, &used_serial_uarts); if (err != ESP_OK && err != ESP_ERR_NOT_FOUND && err != ESP_ERR_NOT_SUPPORTED) { ESP_LOGW(kTag, "gateway=%u KNX/IP 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; } void GatewayBridgeService::collectUsedRuntimeResources( uint8_t except_gateway_id, std::set* modbus_tcp_ports, std::set* knx_udp_ports, std::set* serial_uarts) const { for (const auto& runtime : runtimes_) { if (runtime->channel.gateway_id == except_gateway_id) { continue; } LockGuard guard(runtime->lock); if (runtime->modbus_started) { const auto modbus_config = runtime->activeModbusConfigLocked(); if (modbus_config.has_value()) { if (GatewayModbusTransportIsSerial(modbus_config->transport) && serial_uarts != nullptr) { serial_uarts->insert(modbus_config->serial.uart_port); } else if (GatewayModbusTransportIsTcp(modbus_config->transport) && modbus_tcp_ports != nullptr) { const uint16_t port = modbus_config->port == 0 ? kGatewayModbusDefaultTcpPort : modbus_config->port; modbus_tcp_ports->insert(port); } } } if (runtime->knx_started || (runtime->knx_router != nullptr && runtime->knx_router->started())) { const auto knx_config = runtime->activeKnxConfigLocked(); if (knx_config.has_value() && knx_config->ip_router_enabled) { if (knx_udp_ports != nullptr) { knx_udp_ports->insert(knx_config->udp_port); } if (serial_uarts != nullptr) { serial_uarts->insert(knx_config->tp_uart.uart_port); } } } } } GatewayBridgeHttpResponse GatewayBridgeService::handleGet( const std::string& action_arg, int gateway_id_arg, const std::string& query_arg) { 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"); } 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 == "knx") { return runtime->knxBindingsJson(); } if (action == "bacnet") { return runtime->bacnetBindingsJson(); } if (action == "inventory") { return runtime->inventoryJson(); } if (action == "effective_model") { return runtime->effectiveModelJson(); } 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"); } { LockGuard guard(runtime->lock); const auto* entry = runtime->updateDiscoveryEntryLocked(address.value(), true); if (entry != nullptr && runtime->bacnet_started) { const esp_err_t err = runtime->syncBacnetServerLocked(); if (err != ESP_OK && err != ESP_ERR_NOT_FOUND) { return ErrorResponse(err, "failed to refresh BACnet bridge after discovery"); } runtime->bacnet_started = err == ESP_OK; } if (entry != nullptr) { return JsonOk(DiscoveryEntryToCjson(*entry)); } } return ErrorResponse(ESP_ERR_NOT_FOUND, "device did not respond to type discovery"); } if (action == "base_status" || action == "status_bits") { 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_.baseStatusSnapshot(gateway_id.value(), address.value()), "base status snapshot is unavailable"); } if (action == "dt1" || action == "dt4" || action == "dt5" || action == "dt6" || action == "dt8_status") { 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 == "dt1") { return SnapshotResponse(dali_domain_.dt1Snapshot(gateway_id.value(), address.value()), "DT1 snapshot is unavailable"); } 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"); } if (action == "dt6") { return SnapshotResponse(dali_domain_.dt6Snapshot(gateway_id.value(), address.value()), "DT6 snapshot is unavailable"); } return SnapshotResponse(dali_domain_.dt8StatusSnapshot(gateway_id.value(), address.value()), "DT8 status 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) { const char* message = !runtime->knx_last_error.empty() ? runtime->knx_last_error.c_str() : (!runtime->modbus_last_error.empty() ? runtime->modbus_last_error.c_str() : "failed to save bridge config"); return ErrorResponse(err, message); } 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") { std::set used_modbus_ports; std::set used_serial_uarts; collectUsedRuntimeResources(gateway_id.value(), &used_modbus_ports, nullptr, &used_serial_uarts); const esp_err_t err = runtime->startModbus(&used_modbus_ports, &used_serial_uarts); if (err != ESP_OK) { return ErrorResponse(err, runtime->modbus_last_error.empty() ? "failed to start Modbus bridge" : runtime->modbus_last_error.c_str()); } return handleGet("modbus", gateway_id.value()); } if (action == "modbus_stop") { const esp_err_t err = runtime->stopModbus(); if (err != ESP_OK) { return ErrorResponse(err, "failed to stop Modbus bridge"); } return handleGet("modbus", gateway_id.value()); } if (action == "knx_config" || action == "save_knx") { cJSON* knx_root = cJSON_ParseWithLength(body.data(), body.size()); if (knx_root == nullptr) { return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid KNX config JSON"); } const cJSON* knx_node = cJSON_GetObjectItemCaseSensitive(knx_root, "knx"); if (knx_node == nullptr) { knx_node = knx_root; } const DaliValue knx_value = FromCjson(knx_node); cJSON_Delete(knx_root); const auto parsed = GatewayKnxConfigFromValue(&knx_value); if (!parsed.has_value()) { return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid KNX config"); } std::set used_knx_ports; std::set used_serial_uarts; collectUsedRuntimeResources(gateway_id.value(), nullptr, &used_knx_ports, &used_serial_uarts); const esp_err_t err = runtime->saveKnxConfig(parsed.value(), &used_knx_ports, &used_serial_uarts); if (err != ESP_OK) { return ErrorResponse(err, runtime->knx_last_error.empty() ? "failed to save KNX bridge config" : runtime->knx_last_error.c_str()); } return handleGet("knx", gateway_id.value()); } if (action == "knx_start") { std::set used_knx_ports; std::set used_serial_uarts; collectUsedRuntimeResources(gateway_id.value(), nullptr, &used_knx_ports, &used_serial_uarts); const esp_err_t err = runtime->startKnx(&used_knx_ports, &used_serial_uarts); if (err != ESP_OK) { return ErrorResponse(err, runtime->knx_last_error.empty() ? "failed to start KNX/IP bridge" : runtime->knx_last_error.c_str()); } return handleGet("knx", gateway_id.value()); } if (action == "knx_stop") { const esp_err_t err = runtime->stopKnx(); if (err != ESP_OK) { return ErrorResponse(err, "failed to stop KNX/IP bridge"); } return handleGet("knx", 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()); } if (action == "scan") { return runtime->scanInventory(body); } return ErrorResponse(ESP_ERR_INVALID_ARG, "unknown bridge POST action"); } } // namespace gateway