diff --git a/components/dali_domain/src/dali_domain.cpp b/components/dali_domain/src/dali_domain.cpp index 3f65c55..16b9033 100644 --- a/components/dali_domain/src/dali_domain.cpp +++ b/components/dali_domain/src/dali_domain.cpp @@ -451,7 +451,7 @@ std::optional DaliDomainService::discoverDeviceTypes( if (channel == nullptr || channel->dali == nullptr) { return std::nullopt; } - const std::vector fallback = fallback_types.empty() ? std::vector{4, 5, 6, 8} + const std::vector fallback = fallback_types.empty() ? std::vector{1, 4, 5, 6, 8} : fallback_types; auto discovery = channel->dali->base.discoverDeviceTypes(short_address, fallback, max_next_types); diff --git a/components/gateway_bacnet/include/gateway_bacnet.hpp b/components/gateway_bacnet/include/gateway_bacnet.hpp index 48a8457..4bbd920 100644 --- a/components/gateway_bacnet/include/gateway_bacnet.hpp +++ b/components/gateway_bacnet/include/gateway_bacnet.hpp @@ -30,6 +30,8 @@ struct GatewayBacnetObjectBinding { BridgeObjectType object_type{BridgeObjectType::unknown}; uint32_t object_instance{0}; std::string property{"presentValue"}; + bool out_of_service{false}; + uint32_t reliability{0}; }; struct GatewayBacnetServerStatus { diff --git a/components/gateway_bacnet/include/gateway_bacnet_stack_port.h b/components/gateway_bacnet/include/gateway_bacnet_stack_port.h index 955ae41..af90ac4 100644 --- a/components/gateway_bacnet/include/gateway_bacnet_stack_port.h +++ b/components/gateway_bacnet/include/gateway_bacnet_stack_port.h @@ -48,7 +48,11 @@ bool gateway_bacnet_stack_upsert_object( gateway_bacnet_object_kind_t object_kind, uint32_t object_instance, const char* object_name, - const char* description); + const char* description, + bool out_of_service, + uint32_t reliability); + +bool gateway_bacnet_stack_clear_objects(void); void gateway_bacnet_stack_send_i_am(void); void gateway_bacnet_stack_poll(uint16_t elapsed_ms); diff --git a/components/gateway_bacnet/src/gateway_bacnet.cpp b/components/gateway_bacnet/src/gateway_bacnet.cpp index ee0fe76..e4a959a 100644 --- a/components/gateway_bacnet/src/gateway_bacnet.cpp +++ b/components/gateway_bacnet/src/gateway_bacnet.cpp @@ -124,6 +124,8 @@ struct GatewayBacnetServer::RuntimeBinding { uint32_t object_instance{0}; std::string model_id; std::string property{"presentValue"}; + bool out_of_service{false}; + uint32_t reliability{0}; GatewayBacnetWriteCallback write_callback; }; @@ -168,12 +170,9 @@ esp_err_t GatewayBacnetServer::registerChannel( bindings.erase(std::remove_if(bindings.begin(), bindings.end(), [](const auto& binding) { return !IsSupportedObjectType(binding.object_type) || - binding.object_instance > kMaxBacnetInstance; + binding.object_instance > kMaxBacnetInstance; }), bindings.end()); - if (bindings.empty()) { - return ESP_ERR_NOT_FOUND; - } LockGuard guard(lock_); if (started_ && !configCompatible(config)) { @@ -183,6 +182,9 @@ esp_err_t GatewayBacnetServer::registerChannel( auto channel = std::find_if(channels_.begin(), channels_.end(), [gateway_id](const auto& item) { return item.gateway_id == gateway_id; }); + if (bindings.empty() && !started_ && channel == channels_.end()) { + return ESP_ERR_NOT_FOUND; + } ChannelRegistration registration{gateway_id, config, std::move(bindings), std::move(write_callback)}; if (channel == channels_.end()) { @@ -240,6 +242,10 @@ esp_err_t GatewayBacnetServer::rebuildObjectsLocked() { runtime_bindings_.clear(); std::set> used_objects; + if (!gateway_bacnet_stack_clear_objects()) { + return ESP_FAIL; + } + for (const auto& channel : channels_) { for (const auto& binding : channel.bindings) { const auto key = std::make_pair(binding.object_type, binding.object_instance); @@ -254,7 +260,9 @@ esp_err_t GatewayBacnetServer::rebuildObjectsLocked() { const std::string name = ObjectName(binding); if (!gateway_bacnet_stack_upsert_object(ToBacnetKind(binding.object_type), binding.object_instance, name.c_str(), - binding.model_id.c_str())) { + binding.model_id.c_str(), + binding.out_of_service, + binding.reliability)) { return ESP_FAIL; } runtime_bindings_.push_back(RuntimeBinding{channel.gateway_id, @@ -263,6 +271,8 @@ esp_err_t GatewayBacnetServer::rebuildObjectsLocked() { binding.model_id, binding.property.empty() ? "presentValue" : binding.property, + binding.out_of_service, + binding.reliability, channel.write_callback}); } } diff --git a/components/gateway_bacnet/src/gateway_bacnet_stack_port.c b/components/gateway_bacnet/src/gateway_bacnet_stack_port.c index 2d87b51..8752de1 100644 --- a/components/gateway_bacnet/src/gateway_bacnet_stack_port.c +++ b/components/gateway_bacnet/src/gateway_bacnet_stack_port.c @@ -39,6 +39,91 @@ static const char Multistate_Value_States[] = "State 15\0" "State 16\0"; +static bool clear_analog_value_objects(void) +{ + unsigned count = Analog_Value_Count(); + while (count > 0) { + count--; + Analog_Value_Delete(Analog_Value_Index_To_Instance(count)); + } + return true; +} + +static bool clear_analog_output_objects(void) +{ + unsigned count = Analog_Output_Count(); + while (count > 0) { + count--; + Analog_Output_Delete(Analog_Output_Index_To_Instance(count)); + } + return true; +} + +static bool clear_binary_value_objects(void) +{ + unsigned count = Binary_Value_Count(); + while (count > 0) { + count--; + Binary_Value_Delete(Binary_Value_Index_To_Instance(count)); + } + return true; +} + +static bool clear_binary_output_objects(void) +{ + unsigned count = Binary_Output_Count(); + while (count > 0) { + count--; + Binary_Output_Delete(Binary_Output_Index_To_Instance(count)); + } + return true; +} + +static bool clear_multistate_value_objects(void) +{ + unsigned count = Multistate_Value_Count(); + while (count > 0) { + count--; + Multistate_Value_Delete(Multistate_Value_Index_To_Instance(count)); + } + return true; +} + +static void set_analog_value_state( + uint32_t object_instance, bool out_of_service, BACNET_RELIABILITY reliability) +{ + Analog_Value_Out_Of_Service_Set(object_instance, out_of_service); + Analog_Value_Reliability_Set(object_instance, reliability); +} + +static void set_analog_output_state( + uint32_t object_instance, bool out_of_service, BACNET_RELIABILITY reliability) +{ + Analog_Output_Out_Of_Service_Set(object_instance, out_of_service); + Analog_Output_Reliability_Set(object_instance, reliability); +} + +static void set_binary_value_state( + uint32_t object_instance, bool out_of_service, BACNET_RELIABILITY reliability) +{ + Binary_Value_Out_Of_Service_Set(object_instance, out_of_service); + Binary_Value_Reliability_Set(object_instance, reliability); +} + +static void set_binary_output_state( + uint32_t object_instance, bool out_of_service, BACNET_RELIABILITY reliability) +{ + Binary_Output_Out_Of_Service_Set(object_instance, out_of_service); + Binary_Output_Reliability_Set(object_instance, reliability); +} + +static void set_multistate_value_state( + uint32_t object_instance, bool out_of_service, BACNET_RELIABILITY reliability) +{ + Multistate_Value_Out_Of_Service_Set(object_instance, out_of_service); + Multistate_Value_Reliability_Set(object_instance, reliability); +} + static void notify_write_real( gateway_bacnet_object_kind_t object_kind, uint32_t object_instance, double value) { @@ -209,7 +294,9 @@ bool gateway_bacnet_stack_upsert_object( gateway_bacnet_object_kind_t object_kind, uint32_t object_instance, const char* object_name, - const char* description) + const char* description, + bool out_of_service, + uint32_t reliability) { if (!object_name || object_name[0] == '\0') { object_name = "DALI BACnet Object"; @@ -218,6 +305,8 @@ bool gateway_bacnet_stack_upsert_object( description = ""; } + BACNET_RELIABILITY object_reliability = (BACNET_RELIABILITY)reliability; + switch (object_kind) { case GW_BACNET_OBJECT_ANALOG_VALUE: if (!Analog_Value_Valid_Instance(object_instance)) { @@ -227,6 +316,7 @@ bool gateway_bacnet_stack_upsert_object( Analog_Value_Description_Set(object_instance, description); Analog_Value_Units_Set(object_instance, UNITS_PERCENT); Analog_Value_Present_Value_Set(object_instance, 0.0f, BACNET_NO_PRIORITY); + set_analog_value_state(object_instance, out_of_service, object_reliability); return true; case GW_BACNET_OBJECT_ANALOG_OUTPUT: if (!Analog_Output_Valid_Instance(object_instance)) { @@ -236,6 +326,7 @@ bool gateway_bacnet_stack_upsert_object( Analog_Output_Description_Set(object_instance, description); Analog_Output_Units_Set(object_instance, UNITS_PERCENT); Analog_Output_Present_Value_Set(object_instance, 0.0f, BACNET_MAX_PRIORITY); + set_analog_output_state(object_instance, out_of_service, object_reliability); return true; case GW_BACNET_OBJECT_BINARY_VALUE: if (!Binary_Value_Valid_Instance(object_instance)) { @@ -245,6 +336,7 @@ bool gateway_bacnet_stack_upsert_object( Binary_Value_Description_Set(object_instance, description); Binary_Value_Write_Enable(object_instance); Binary_Value_Present_Value_Set(object_instance, BINARY_INACTIVE); + set_binary_value_state(object_instance, out_of_service, object_reliability); return true; case GW_BACNET_OBJECT_BINARY_OUTPUT: if (!Binary_Output_Valid_Instance(object_instance)) { @@ -253,6 +345,7 @@ bool gateway_bacnet_stack_upsert_object( Binary_Output_Name_Set(object_instance, object_name); Binary_Output_Description_Set(object_instance, description); Binary_Output_Present_Value_Set(object_instance, BINARY_INACTIVE, BACNET_MAX_PRIORITY); + set_binary_output_state(object_instance, out_of_service, object_reliability); return true; case GW_BACNET_OBJECT_MULTI_STATE_VALUE: if (!Multistate_Value_Valid_Instance(object_instance)) { @@ -263,12 +356,22 @@ bool gateway_bacnet_stack_upsert_object( Multistate_Value_State_Text_List_Set(object_instance, Multistate_Value_States); Multistate_Value_Write_Enable(object_instance); Multistate_Value_Present_Value_Set(object_instance, 1); + set_multistate_value_state(object_instance, out_of_service, object_reliability); return true; default: return false; } } +bool gateway_bacnet_stack_clear_objects(void) +{ + return clear_analog_value_objects() && + clear_analog_output_objects() && + clear_binary_value_objects() && + clear_binary_output_objects() && + clear_multistate_value_objects(); +} + void gateway_bacnet_stack_send_i_am(void) { Send_I_Am(&Handler_Transmit_Buffer[0]); diff --git a/components/gateway_bridge/include/gateway_bridge.hpp b/components/gateway_bridge/include/gateway_bridge.hpp index e4b0f97..a8a74a3 100644 --- a/components/gateway_bridge/include/gateway_bridge.hpp +++ b/components/gateway_bridge/include/gateway_bridge.hpp @@ -41,7 +41,7 @@ class GatewayBridgeService { esp_err_t start(); GatewayBridgeHttpResponse handleGet(const std::string& action, int gateway_id = -1, - const std::string& query = {}) const; + const std::string& query = {}); GatewayBridgeHttpResponse handlePost(const std::string& action, int gateway_id, const std::string& body); diff --git a/components/gateway_bridge/src/gateway_bridge.cpp b/components/gateway_bridge/src/gateway_bridge.cpp index 76d193b..acb1d14 100644 --- a/components/gateway_bridge/src/gateway_bridge.cpp +++ b/components/gateway_bridge/src/gateway_bridge.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -35,6 +36,18 @@ namespace { constexpr const char* kTag = "gateway_bridge"; constexpr int kDefaultModbusPort = 1502; constexpr size_t kModbusMaxPduBytes = 252; +constexpr const char* kDiscoveryInventoryKey = "bridge_disc"; +constexpr int kMaxDaliShortAddress = 63; +constexpr uint32_t kBacnetReliabilityNoFaultDetected = 0; +constexpr uint32_t kBacnetReliabilityCommunicationFailure = 12; + +struct BridgeDiscoveryEntry { + int short_address{0}; + bool online{true}; + DaliDomainSnapshot discovery; +}; + +using BridgeDiscoveryInventory = std::map; class LockGuard { public: @@ -164,6 +177,69 @@ bool ValidDaliAddress(int address) { return address >= 0 && address <= 127; } +bool ValidShortAddress(int address) { + return address >= 0 && address <= kMaxDaliShortAddress; +} + +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(); +} + +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; + } +} + +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) { @@ -212,6 +288,248 @@ cJSON* SnapshotToCjson(const DaliDomainSnapshot& snapshot) { 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()) { @@ -568,8 +886,10 @@ struct GatewayBridgeService::ChannelRuntime { #endif std::unique_ptr cloud; BridgeRuntimeConfig bridge_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}; @@ -601,6 +921,11 @@ struct GatewayBridgeService::ChannelRuntime { BridgeProvisioningStore bridge_store(bridgeNamespace()); bridge_config_loaded = bridge_store.load(&bridge_config) == ESP_OK; + 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()); @@ -643,6 +968,98 @@ struct GatewayBridgeService::ChannelRuntime { bacnet_started = false; } + 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); + }); +#else + return ESP_ERR_NOT_SUPPORTED; +#endif + } + void applyCloudModelsLocked() { if (cloud_started && cloud != nullptr) { cloud->stop(); @@ -760,6 +1177,45 @@ struct GatewayBridgeService::ChannelRuntime { return model == bridge_config.models.end() ? model_id : model->displayName(); } + bool shouldPublishBacnetBindingLocked(const BacnetObjectBinding& 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; @@ -776,22 +1232,32 @@ struct GatewayBridgeService::ChannelRuntime { return config; } - std::vector bacnetObjectBindingsLocked() const { + std::vector bacnetObjectBindingsLocked() { std::vector bindings; - if (bacnet == nullptr) { - return bindings; - } - for (const auto& binding : bacnet->describeObjects()) { + 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}); + : binding.property, + out_of_service, + reliability}); } return bindings; } @@ -819,17 +1285,7 @@ struct GatewayBridgeService::ChannelRuntime { if (bacnet == nullptr) { return ESP_ERR_INVALID_STATE; } - const auto bindings = bacnetObjectBindingsLocked(); - if (bindings.empty()) { - return ESP_ERR_NOT_FOUND; - } - const auto server_config = bacnetServerConfigLocked(); - const esp_err_t err = GatewayBacnetServer::instance().registerChannel( - channel.gateway_id, server_config, bindings, - [this](BridgeObjectType object_type, uint32_t object_instance, - const std::string& property, const DaliValue& value) { - return handleBacnetWrite(object_type, object_instance, property, value); - }); + const esp_err_t err = syncBacnetServerLocked(); bacnet_started = err == ESP_OK; return err; } @@ -864,6 +1320,9 @@ struct GatewayBridgeService::ChannelRuntime { 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(); @@ -922,6 +1381,154 @@ struct GatewayBridgeService::ChannelRuntime { return GatewayBridgeHttpResponse{ESP_OK, BridgeRuntimeConfigToJson(bridge_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); @@ -941,13 +1548,13 @@ struct GatewayBridgeService::ChannelRuntime { return JsonOk(root); } - GatewayBridgeHttpResponse bacnetBindingsJson() const { + 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 : bacnet->describeObjects()) { + for (const auto& binding : effectiveBacnetObjectsLocked()) { cJSON* item = cJSON_CreateObject(); if (item == nullptr) { continue; @@ -956,6 +1563,36 @@ struct GatewayBridgeService::ChannelRuntime { 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); } } @@ -1226,7 +1863,7 @@ const GatewayBridgeService::ChannelRuntime* GatewayBridgeService::findRuntime( } GatewayBridgeHttpResponse GatewayBridgeService::handleGet( - const std::string& action_arg, int gateway_id_arg, const std::string& query_arg) const { + 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"); } @@ -1257,7 +1894,7 @@ GatewayBridgeHttpResponse GatewayBridgeService::handleGet( if (!gateway_id.has_value()) { return ErrorResponse(ESP_ERR_INVALID_ARG, "gateway id is required"); } - const auto* runtime = findRuntime(gateway_id.value()); + auto* runtime = findRuntime(gateway_id.value()); if (runtime == nullptr) { return ErrorResponse(ESP_ERR_NOT_FOUND, "unknown gateway id"); } @@ -1274,6 +1911,12 @@ GatewayBridgeHttpResponse GatewayBridgeService::handleGet( 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(); } @@ -1282,8 +1925,21 @@ GatewayBridgeHttpResponse GatewayBridgeService::handleGet( if (!address.has_value() || !ValidDaliAddress(address.value())) { return ErrorResponse(ESP_ERR_INVALID_ARG, "valid addr is required"); } - return SnapshotResponse(dali_domain_.discoverDeviceTypes(gateway_id.value(), address.value()), - "device did not respond to type discovery"); + { + 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 == "dt4" || action == "dt5" || action == "dt6") { const auto address = QueryInt(query, "addr", "address"); @@ -1443,6 +2099,9 @@ GatewayBridgeHttpResponse GatewayBridgeService::handlePost( } return handleGet("bacnet", gateway_id.value()); } + if (action == "scan") { + return runtime->scanInventory(body); + } return ErrorResponse(ESP_ERR_INVALID_ARG, "unknown bridge POST action"); }