diff --git a/apps/gateway/main/CMakeLists.txt b/apps/gateway/main/CMakeLists.txt index 63cbb34..7ae7970 100644 --- a/apps/gateway/main/CMakeLists.txt +++ b/apps/gateway/main/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register( SRCS "app_main.cpp" - REQUIRES gateway_core gateway_controller gateway_network dali_domain gateway_runtime gateway_ble gateway_usb_setup log + REQUIRES gateway_core gateway_controller gateway_network gateway_bridge dali_domain gateway_runtime gateway_ble gateway_usb_setup log ) set_property(TARGET ${COMPONENT_LIB} PROPERTY CXX_STANDARD 17) \ No newline at end of file diff --git a/apps/gateway/main/Kconfig.projbuild b/apps/gateway/main/Kconfig.projbuild index af10850..cf9ff36 100644 --- a/apps/gateway/main/Kconfig.projbuild +++ b/apps/gateway/main/Kconfig.projbuild @@ -322,6 +322,79 @@ config GATEWAY_SMARTCONFIG_TIMEOUT_SEC help Timeout passed to ESP-IDF smartconfig before provisioning restarts internally. +config GATEWAY_BRIDGE_SUPPORTED + bool "dali_cpp bridge runtime is supported" + default y + help + Enables per-channel dali_cpp bridge model provisioning, execution, and protocol adapter state. + +config GATEWAY_MODBUS_BRIDGE_SUPPORTED + bool "Modbus TCP bridge is supported" + depends on GATEWAY_BRIDGE_SUPPORTED && GATEWAY_WIFI_SUPPORTED + default y + help + Enables the per-channel Modbus TCP adapter backed by DaliModbusBridge. Runtime startup still requires persisted bridge config with Modbus settings. + +config GATEWAY_START_MODBUS_BRIDGE_ENABLED + bool "Start Modbus TCP bridge at startup" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED + default n + help + Starts configured Modbus TCP listeners at boot. Disabled by default so ports are opened only after provisioning or explicit runtime start. + +config GATEWAY_BACNET_BRIDGE_SUPPORTED + bool "BACnet/IP bridge is supported" + depends on GATEWAY_BRIDGE_SUPPORTED && GATEWAY_WIFI_SUPPORTED + default n + help + Enables BACnet bridge configuration, binding discovery, and the bacnet-stack BACnet/IP server adapter. + Disable this option for smaller flash builds that do not need BACnet/IP. + +config GATEWAY_START_BACNET_BRIDGE_ENABLED + bool "Start BACnet/IP bridge at startup" + depends on GATEWAY_BACNET_BRIDGE_SUPPORTED + default n + help + Starts configured BACnet/IP object bindings at boot. Disabled by default so the UDP BACnet/IP port is opened only after provisioning or explicit runtime start. + +config GATEWAY_CLOUD_BRIDGE_SUPPORTED + bool "MQTT cloud bridge is supported" + depends on GATEWAY_BRIDGE_SUPPORTED && GATEWAY_WIFI_SUPPORTED + default y + help + Enables per-channel DaliCloudBridge provisioning and MQTT downlink execution. + +config GATEWAY_START_CLOUD_BRIDGE_ENABLED + bool "Start MQTT cloud bridge at startup" + depends on GATEWAY_CLOUD_BRIDGE_SUPPORTED + default n + help + Starts configured MQTT cloud bridges at boot when broker URI and device id are present. + +config GATEWAY_BRIDGE_MODBUS_TASK_STACK_SIZE + int "Modbus bridge task stack bytes" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED + range 4096 16384 + default 6144 + +config GATEWAY_BRIDGE_MODBUS_TASK_PRIORITY + int "Modbus bridge task priority" + depends on GATEWAY_MODBUS_BRIDGE_SUPPORTED + range 1 10 + default 4 + +config GATEWAY_BRIDGE_BACNET_TASK_STACK_SIZE + int "BACnet/IP bridge task stack bytes" + depends on GATEWAY_BACNET_BRIDGE_SUPPORTED + range 6144 24576 + default 8192 + +config GATEWAY_BRIDGE_BACNET_TASK_PRIORITY + int "BACnet/IP bridge task priority" + depends on GATEWAY_BACNET_BRIDGE_SUPPORTED + range 1 10 + default 5 + choice GATEWAY_USB_STARTUP_MODE prompt "USB Serial/JTAG startup mode" default GATEWAY_USB_STARTUP_DEBUG_JTAG diff --git a/apps/gateway/main/app_main.cpp b/apps/gateway/main/app_main.cpp index b6e731f..6309133 100644 --- a/apps/gateway/main/app_main.cpp +++ b/apps/gateway/main/app_main.cpp @@ -1,5 +1,6 @@ #include "dali_domain.hpp" #include "gateway_ble.hpp" +#include "gateway_bridge.hpp" #include "gateway_controller.hpp" #include "gateway_core.hpp" #include "gateway_network.hpp" @@ -57,6 +58,22 @@ #define CONFIG_GATEWAY_SMARTCONFIG_TIMEOUT_SEC 60 #endif +#ifndef CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_STACK_SIZE +#define CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_STACK_SIZE 6144 +#endif + +#ifndef CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_PRIORITY +#define CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_PRIORITY 4 +#endif + +#ifndef CONFIG_GATEWAY_BRIDGE_BACNET_TASK_STACK_SIZE +#define CONFIG_GATEWAY_BRIDGE_BACNET_TASK_STACK_SIZE 8192 +#endif + +#ifndef CONFIG_GATEWAY_BRIDGE_BACNET_TASK_PRIORITY +#define CONFIG_GATEWAY_BRIDGE_BACNET_TASK_PRIORITY 5 +#endif + namespace { constexpr const char* kProjectName = "DALI_485_Gateway"; constexpr const char* kProjectVersion = "0.1.0"; @@ -116,9 +133,52 @@ constexpr bool kUsbSetupStartupEnabled = true; constexpr bool kUsbSetupStartupEnabled = false; #endif +#ifdef CONFIG_GATEWAY_BRIDGE_SUPPORTED +constexpr bool kBridgeSupported = true; +#else +constexpr bool kBridgeSupported = false; +#endif + +#ifdef CONFIG_GATEWAY_MODBUS_BRIDGE_SUPPORTED +constexpr bool kModbusBridgeSupported = true; +#else +constexpr bool kModbusBridgeSupported = false; +#endif + +#ifdef CONFIG_GATEWAY_START_MODBUS_BRIDGE_ENABLED +constexpr bool kModbusBridgeStartupEnabled = true; +#else +constexpr bool kModbusBridgeStartupEnabled = false; +#endif + +#ifdef CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED +constexpr bool kBacnetBridgeSupported = true; +#else +constexpr bool kBacnetBridgeSupported = false; +#endif + +#ifdef CONFIG_GATEWAY_START_BACNET_BRIDGE_ENABLED +constexpr bool kBacnetBridgeStartupEnabled = true; +#else +constexpr bool kBacnetBridgeStartupEnabled = false; +#endif + +#ifdef CONFIG_GATEWAY_CLOUD_BRIDGE_SUPPORTED +constexpr bool kCloudBridgeSupported = true; +#else +constexpr bool kCloudBridgeSupported = false; +#endif + +#ifdef CONFIG_GATEWAY_START_CLOUD_BRIDGE_ENABLED +constexpr bool kCloudBridgeStartupEnabled = true; +#else +constexpr bool kCloudBridgeStartupEnabled = false; +#endif + std::unique_ptr s_dali_domain; std::unique_ptr s_runtime; std::unique_ptr s_controller; +std::unique_ptr s_bridge; std::unique_ptr s_network; std::unique_ptr s_ble_bridge; std::unique_ptr s_usb_setup_bridge; @@ -361,6 +421,29 @@ extern "C" void app_main(void) { controller_config); ESP_ERROR_CHECK(s_controller->start()); + if (kBridgeSupported) { + gateway::GatewayBridgeServiceConfig bridge_config; + bridge_config.bridge_enabled = true; + bridge_config.modbus_enabled = profile.enable_wifi && kModbusBridgeSupported; + bridge_config.modbus_startup_enabled = profile.enable_wifi && kModbusBridgeSupported && + kModbusBridgeStartupEnabled; + bridge_config.bacnet_enabled = profile.enable_wifi && kBacnetBridgeSupported; + bridge_config.bacnet_startup_enabled = profile.enable_wifi && kBacnetBridgeSupported && + kBacnetBridgeStartupEnabled; + bridge_config.cloud_enabled = profile.enable_wifi && kCloudBridgeSupported; + bridge_config.cloud_startup_enabled = profile.enable_wifi && kCloudBridgeSupported && + kCloudBridgeStartupEnabled; + bridge_config.modbus_task_stack_size = + static_cast(CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_STACK_SIZE); + bridge_config.modbus_task_priority = + static_cast(CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_PRIORITY); + bridge_config.bacnet_task_stack_size = + static_cast(CONFIG_GATEWAY_BRIDGE_BACNET_TASK_STACK_SIZE); + bridge_config.bacnet_task_priority = + static_cast(CONFIG_GATEWAY_BRIDGE_BACNET_TASK_PRIORITY); + s_bridge = std::make_unique(*s_dali_domain, bridge_config); + } + if (profile.enable_wifi || profile.enable_eth) { gateway::GatewayNetworkServiceConfig network_config; network_config.wifi_enabled = profile.enable_wifi && kWifiStartupEnabled; @@ -397,10 +480,15 @@ extern "C" void app_main(void) { network_config.boot_button_active_low = false; #endif s_network = std::make_unique(*s_controller, *s_runtime, - *s_dali_domain, network_config); + *s_dali_domain, network_config, + s_bridge.get()); ESP_ERROR_CHECK(s_network->start()); } + if (s_bridge != nullptr) { + ESP_ERROR_CHECK(s_bridge->start()); + } + if (profile.enable_ble) { s_ble_bridge = std::make_unique(*s_controller, *s_runtime, *s_dali_domain); diff --git a/apps/gateway/partitions.csv b/apps/gateway/partitions.csv index da28208..87e395e 100644 --- a/apps/gateway/partitions.csv +++ b/apps/gateway/partitions.csv @@ -2,7 +2,7 @@ nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, phy_init, data, phy, 0x11000, 0x1000, -factory, app, factory, 0x20000, 0x180000, -ota_0, app, ota_0, 0x1a0000, 0x180000, -ota_1, app, ota_1, 0x320000, 0x180000, -storage, data, spiffs, 0x4a0000, 0xb60000, \ No newline at end of file +factory, app, factory, 0x20000, 0x400000, +ota_0, app, ota_0, 0x420000, 0x400000, +ota_1, app, ota_1, 0x820000, 0x400000, +storage, data, spiffs, 0xc20000, 0x3e0000, \ No newline at end of file diff --git a/apps/gateway/sdkconfig b/apps/gateway/sdkconfig index 820310c..c6dbe3c 100644 --- a/apps/gateway/sdkconfig +++ b/apps/gateway/sdkconfig @@ -632,6 +632,17 @@ CONFIG_GATEWAY_SMARTCONFIG_SUPPORTED=y # CONFIG_GATEWAY_START_ESPNOW_SETUP_ENABLED is not set # CONFIG_GATEWAY_START_SMARTCONFIG_ENABLED is not set CONFIG_GATEWAY_SMARTCONFIG_TIMEOUT_SEC=60 +CONFIG_GATEWAY_BRIDGE_SUPPORTED=y +CONFIG_GATEWAY_MODBUS_BRIDGE_SUPPORTED=y +# CONFIG_GATEWAY_START_MODBUS_BRIDGE_ENABLED is not set +CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED=y +# CONFIG_GATEWAY_START_BACNET_BRIDGE_ENABLED is not set +CONFIG_GATEWAY_CLOUD_BRIDGE_SUPPORTED=y +# CONFIG_GATEWAY_START_CLOUD_BRIDGE_ENABLED is not set +CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_STACK_SIZE=6144 +CONFIG_GATEWAY_BRIDGE_MODBUS_TASK_PRIORITY=4 +CONFIG_GATEWAY_BRIDGE_BACNET_TASK_STACK_SIZE=8192 +CONFIG_GATEWAY_BRIDGE_BACNET_TASK_PRIORITY=5 CONFIG_GATEWAY_USB_STARTUP_DEBUG_JTAG=y # CONFIG_GATEWAY_USB_STARTUP_SETUP_SERIAL is not set # end of Gateway Startup Services diff --git a/components/dali_domain/include/dali_domain.hpp b/components/dali_domain/include/dali_domain.hpp index 43ec4e6..70d4561 100644 --- a/components/dali_domain/include/dali_domain.hpp +++ b/components/dali_domain/include/dali_domain.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -75,6 +76,23 @@ struct DaliRawFrame { std::vector data; }; +enum class DaliDt8SceneColorMode { + kDisabled, + kColorTemperature, + kRgb, +}; + +struct DaliDomainSnapshot { + uint8_t gateway_id{0}; + int address{0}; + std::string kind; + std::map bools; + std::map ints; + std::map numbers; + std::map> int_arrays; + std::map> number_arrays; +}; + class DaliDomainService { public: DaliDomainService(); @@ -97,6 +115,24 @@ class DaliDomainService { bool sendRaw(uint8_t gateway_id, uint8_t raw_addr, uint8_t command) const; bool sendExtRaw(uint8_t gateway_id, uint8_t raw_addr, uint8_t command) const; std::optional queryRaw(uint8_t gateway_id, uint8_t raw_addr, uint8_t command) const; + std::optional discoverDeviceTypes( + uint8_t gateway_id, int short_address, const std::vector& fallback_types = {}, + int max_next_types = 16) const; + std::optional dt4Snapshot(uint8_t gateway_id, int short_address) const; + std::optional dt5Snapshot(uint8_t gateway_id, int short_address) const; + std::optional dt6Snapshot(uint8_t gateway_id, int short_address) const; + std::optional dt8SceneColorReport(uint8_t gateway_id, int short_address, + int scene) const; + std::optional dt8PowerOnLevelColorReport(uint8_t gateway_id, + int short_address) const; + std::optional dt8SystemFailureLevelColorReport(uint8_t gateway_id, + int short_address) const; + bool storeDt8SceneSnapshot(uint8_t gateway_id, int short_address, int scene, int brightness, + DaliDt8SceneColorMode color_mode = DaliDt8SceneColorMode::kDisabled, + int color_temperature = 0, int red = 0, int green = 0, + int blue = 0) const; + bool storeDt8PowerOnLevelSnapshot(uint8_t gateway_id, int short_address, int level) const; + bool storeDt8SystemFailureLevelSnapshot(uint8_t gateway_id, int short_address, int level) const; bool setBright(uint8_t gateway_id, int short_address, int brightness) const; bool setColTempRaw(uint8_t gateway_id, int short_address, int mirek) const; bool setColTemp(uint8_t gateway_id, int short_address, int kelvin) const; diff --git a/components/dali_domain/src/dali_domain.cpp b/components/dali_domain/src/dali_domain.cpp index 4e2affc..c1b3a09 100644 --- a/components/dali_domain/src/dali_domain.cpp +++ b/components/dali_domain/src/dali_domain.cpp @@ -18,6 +18,47 @@ constexpr const char* kTag = "dali_domain"; constexpr size_t kSerialRxPacketMaxBytes = 8; constexpr UBaseType_t kSerialRxQueueDepth = 8; +DaliDomainSnapshot MakeSnapshot(uint8_t gateway_id, int address, const char* kind) { + DaliDomainSnapshot snapshot; + snapshot.gateway_id = gateway_id; + snapshot.address = address; + snapshot.kind = kind == nullptr ? "" : kind; + return snapshot; +} + +template +void PutOptionalInt(DaliDomainSnapshot& snapshot, const char* name, const std::optional& value) { + if (value.has_value()) { + snapshot.ints[name] = static_cast(value.value()); + } +} + +void PutOptionalBool(DaliDomainSnapshot& snapshot, const char* name, + const std::optional& value) { + if (value.has_value()) { + snapshot.bools[name] = value.value(); + } +} + +void PutOptionalNumber(DaliDomainSnapshot& snapshot, const char* name, + const std::optional& value) { + if (value.has_value()) { + snapshot.numbers[name] = value.value(); + } +} + +Dt8SceneStoreColorMode ToDaliCppColorMode(DaliDt8SceneColorMode color_mode) { + switch (color_mode) { + case DaliDt8SceneColorMode::kColorTemperature: + return Dt8SceneStoreColorMode::colorTemperature; + case DaliDt8SceneColorMode::kRgb: + return Dt8SceneStoreColorMode::rgb; + case DaliDt8SceneColorMode::kDisabled: + default: + return Dt8SceneStoreColorMode::disabled; + } +} + struct SerialRxPacket { size_t len{0}; uint8_t data[kSerialRxPacketMaxBytes]{}; @@ -403,6 +444,292 @@ std::optional DaliDomainService::queryRaw(uint8_t gateway_id, uint8_t r return channel->comm->queryRawNew(raw_addr, command); } +std::optional DaliDomainService::discoverDeviceTypes( + uint8_t gateway_id, int short_address, const std::vector& fallback_types, + int max_next_types) const { + const auto* channel = findChannelByGateway(gateway_id); + if (channel == nullptr || channel->dali == nullptr) { + return std::nullopt; + } + const std::vector fallback = fallback_types.empty() ? std::vector{4, 5, 6, 8} + : fallback_types; + auto discovery = channel->dali->base.discoverDeviceTypes(short_address, fallback, + max_next_types); + if (!discovery.has_value()) { + return std::nullopt; + } + auto snapshot = MakeSnapshot(gateway_id, short_address, "device"); + PutOptionalInt(snapshot, "rawQueryType", discovery->rawQueryType); + PutOptionalInt(snapshot, "primaryType", discovery->primaryType()); + snapshot.int_arrays["types"] = discovery->types; + snapshot.int_arrays["extraTypes"] = discovery->extraTypes(); + return snapshot; +} + +std::optional DaliDomainService::dt4Snapshot(uint8_t gateway_id, + int short_address) const { + const auto* channel = findChannelByGateway(gateway_id); + if (channel == nullptr || channel->dali == nullptr) { + return std::nullopt; + } + auto snapshot = MakeSnapshot(gateway_id, short_address, "dt4"); + auto& dt4 = channel->dali->dt4; + PutOptionalInt(snapshot, "extendedVersion", dt4.getExtendedVersion(short_address)); + PutOptionalInt(snapshot, "dimmingCurve", dt4.getDimmingCurve(short_address)); + PutOptionalInt(snapshot, "dimmerTemperatureRaw", dt4.getDimmerTemperatureRaw(short_address)); + PutOptionalInt(snapshot, "rmsSupplyVoltageRaw", dt4.getRmsSupplyVoltageRaw(short_address)); + PutOptionalInt(snapshot, "supplyFrequencyRaw", dt4.getSupplyFrequencyRaw(short_address)); + PutOptionalInt(snapshot, "rmsLoadVoltageRaw", dt4.getRmsLoadVoltageRaw(short_address)); + PutOptionalInt(snapshot, "rmsLoadCurrentRaw", dt4.getRmsLoadCurrentRaw(short_address)); + PutOptionalInt(snapshot, "realLoadPowerRaw", dt4.getRealLoadPowerRaw(short_address)); + PutOptionalInt(snapshot, "loadRatingRaw", dt4.getLoadRatingRaw(short_address)); + PutOptionalNumber(snapshot, "rmsSupplyVoltageVolts", dt4.getRmsSupplyVoltageVolts(short_address)); + PutOptionalNumber(snapshot, "supplyFrequencyHertz", dt4.getSupplyFrequencyHertz(short_address)); + PutOptionalNumber(snapshot, "rmsLoadVoltageVolts", dt4.getRmsLoadVoltageVolts(short_address)); + PutOptionalNumber(snapshot, "rmsLoadCurrentPercent", dt4.getRmsLoadCurrentPercent(short_address)); + PutOptionalNumber(snapshot, "realLoadPowerWatts", dt4.getRealLoadPowerWatts(short_address)); + PutOptionalNumber(snapshot, "loadRatingAmps", dt4.getLoadRatingAmps(short_address)); + PutOptionalBool(snapshot, "referenceRunning", dt4.isReferenceRunning(short_address)); + PutOptionalBool(snapshot, "referenceMeasurementFailed", + dt4.isReferenceMeasurementFailed(short_address)); + + if (const auto status = dt4.getDimmerStatus(short_address)) { + snapshot.ints["dimmerStatusRaw"] = status->raw(); + snapshot.bools["leadingEdgeModeRunning"] = status->leadingEdgeModeRunning(); + snapshot.bools["trailingEdgeModeRunning"] = status->trailingEdgeModeRunning(); + snapshot.bools["referenceMeasurementRunning"] = status->referenceMeasurementRunning(); + snapshot.bools["nonLogarithmicDimmingCurveActive"] = + status->nonLogarithmicDimmingCurveActive(); + } + if (const auto features = dt4.getFeatures(short_address)) { + snapshot.ints["featuresRaw1"] = features->raw1(); + snapshot.ints["featuresRaw2"] = features->raw2(); + snapshot.ints["featuresRaw3"] = features->raw3(); + snapshot.ints["dimmingMethodCode"] = features->dimmingMethodCode(); + snapshot.bools["canQueryTemperature"] = features->canQueryTemperature(); + snapshot.bools["canQuerySupplyVoltage"] = features->canQuerySupplyVoltage(); + snapshot.bools["canQueryLoadVoltage"] = features->canQueryLoadVoltage(); + snapshot.bools["canQueryLoadCurrent"] = features->canQueryLoadCurrent(); + snapshot.bools["canQueryRealLoadPower"] = features->canQueryRealLoadPower(); + snapshot.bools["canQueryLoadRating"] = features->canQueryLoadRating(); + snapshot.bools["physicalSelectionSupported"] = features->physicalSelectionSupported(); + snapshot.bools["canSelectNonLogarithmicDimmingCurve"] = + features->canSelectNonLogarithmicDimmingCurve(); + } + if (const auto failure = dt4.getFailureStatus(short_address)) { + snapshot.ints["failureRaw1"] = failure->raw1(); + snapshot.ints["failureRaw2"] = failure->raw2(); + snapshot.bools["loadOverCurrentShutdown"] = failure->loadOverCurrentShutdown(); + snapshot.bools["openCircuitDetected"] = failure->openCircuitDetected(); + snapshot.bools["loadDecreaseDetected"] = failure->loadDecreaseDetected(); + snapshot.bools["loadIncreaseDetected"] = failure->loadIncreaseDetected(); + snapshot.bools["thermalShutdown"] = failure->thermalShutdown(); + snapshot.bools["thermalOverloadReduction"] = failure->thermalOverloadReduction(); + snapshot.bools["referenceMeasurementFailedStatus"] = failure->referenceMeasurementFailed(); + snapshot.bools["supplyVoltageOutOfLimits"] = failure->supplyVoltageOutOfLimits(); + snapshot.bools["loadVoltageOutOfLimits"] = failure->loadVoltageOutOfLimits(); + snapshot.bools["loadCurrentOverloadReduction"] = failure->loadCurrentOverloadReduction(); + } + return snapshot; +} + +std::optional DaliDomainService::dt5Snapshot(uint8_t gateway_id, + int short_address) const { + const auto* channel = findChannelByGateway(gateway_id); + if (channel == nullptr || channel->dali == nullptr) { + return std::nullopt; + } + auto snapshot = MakeSnapshot(gateway_id, short_address, "dt5"); + auto& dt5 = channel->dali->dt5; + PutOptionalInt(snapshot, "extendedVersion", dt5.getExtendedVersion(short_address)); + PutOptionalInt(snapshot, "dimmingCurve", dt5.getDimmingCurve(short_address)); + PutOptionalInt(snapshot, "outputLevelRaw", dt5.getOutputLevelRaw(short_address)); + PutOptionalNumber(snapshot, "outputLevelVolts", dt5.getOutputLevelVolts(short_address)); + if (const auto features = dt5.getConverterFeatures(short_address)) { + snapshot.ints["featuresRaw"] = features->raw(); + snapshot.bools["outputRange0To10VSelectable"] = features->outputRange0To10VSelectable(); + snapshot.bools["internalPullUpSelectable"] = features->internalPullUpSelectable(); + snapshot.bools["outputFaultDetectionSelectable"] = + features->outputFaultDetectionSelectable(); + snapshot.bools["mainsRelay"] = features->mainsRelay(); + snapshot.bools["outputLevelQueryable"] = features->outputLevelQueryable(); + snapshot.bools["nonLogarithmicDimmingCurveSupported"] = + features->nonLogarithmicDimmingCurveSupported(); + snapshot.bools["physicalSelectionByOutputLossSupported"] = + features->physicalSelectionByOutputLossSupported(); + snapshot.bools["physicalSelectionSwitchSupported"] = + features->physicalSelectionSwitchSupported(); + } + if (const auto failure = dt5.getFailureStatus(short_address)) { + snapshot.ints["failureRaw"] = failure->raw(); + snapshot.bools["outputFaultDetected"] = failure->outputFaultDetected(); + } + if (const auto status = dt5.getConverterStatus(short_address)) { + snapshot.ints["converterStatusRaw"] = status->raw(); + snapshot.bools["zeroToTenVoltOperation"] = status->zeroToTenVoltOperation(); + snapshot.bools["internalPullUpOn"] = status->internalPullUpOn(); + snapshot.bools["nonLogarithmicDimmingCurveActive"] = + status->nonLogarithmicDimmingCurveActive(); + } + return snapshot; +} + +std::optional DaliDomainService::dt6Snapshot(uint8_t gateway_id, + int short_address) const { + const auto* channel = findChannelByGateway(gateway_id); + if (channel == nullptr || channel->dali == nullptr) { + return std::nullopt; + } + auto snapshot = MakeSnapshot(gateway_id, short_address, "dt6"); + auto& dt6 = channel->dali->dt6; + PutOptionalInt(snapshot, "extendedVersion", dt6.getExtendedVersion(short_address)); + PutOptionalInt(snapshot, "dimmingCurve", dt6.getDimmingCurve(short_address)); + PutOptionalInt(snapshot, "fastFadeTime", dt6.getFastFadeTime(short_address)); + PutOptionalInt(snapshot, "minFastFadeTime", dt6.getMinFastFadeTime(short_address)); + PutOptionalBool(snapshot, "currentProtectorEnabled", + dt6.isCurrentProtectorEnabled(short_address)); + if (const auto gear = dt6.getGearType(short_address)) { + snapshot.ints["gearTypeRaw"] = gear->raw(); + snapshot.bools["ledPowerSupplyIntegrated"] = gear->ledPowerSupplyIntegrated(); + snapshot.bools["ledModuleIntegrated"] = gear->ledModuleIntegrated(); + snapshot.bools["acSupplyPossible"] = gear->acSupplyPossible(); + snapshot.bools["dcSupplyPossible"] = gear->dcSupplyPossible(); + } + if (const auto modes = dt6.getPossibleOperatingModes(short_address)) { + snapshot.ints["possibleOperatingModesRaw"] = modes->raw(); + snapshot.bools["pwmModePossible"] = modes->pwmModePossible(); + snapshot.bools["amModePossible"] = modes->amModePossible(); + snapshot.bools["currentControlledOutputPossible"] = modes->currentControlledOutput(); + snapshot.bools["highCurrentPulseModePossible"] = modes->highCurrentPulseMode(); + } + if (const auto features = dt6.getFeatures(short_address)) { + snapshot.ints["featuresRaw"] = features->raw(); + snapshot.bools["canQueryShortCircuit"] = features->canQueryShortCircuit(); + snapshot.bools["canQueryOpenCircuit"] = features->canQueryOpenCircuit(); + snapshot.bools["canQueryLoadDecrease"] = features->canQueryLoadDecrease(); + snapshot.bools["canQueryLoadIncrease"] = features->canQueryLoadIncrease(); + snapshot.bools["canQueryCurrentProtector"] = features->canQueryCurrentProtector(); + snapshot.bools["canQueryThermalShutdown"] = features->canQueryThermalShutdown(); + snapshot.bools["canQueryThermalOverloadReduction"] = + features->canQueryThermalOverloadReduction(); + snapshot.bools["physicalSelectionSupported"] = features->physicalSelectionSupported(); + } + if (const auto failure = dt6.getFailureStatus(short_address)) { + snapshot.ints["failureRaw"] = failure->raw(); + snapshot.bools["shortCircuit"] = failure->shortCircuit(); + snapshot.bools["openCircuit"] = failure->openCircuit(); + snapshot.bools["loadDecrease"] = failure->loadDecrease(); + snapshot.bools["loadIncrease"] = failure->loadIncrease(); + snapshot.bools["currentProtectorActive"] = failure->currentProtectorActive(); + snapshot.bools["thermalShutdown"] = failure->thermalShutdown(); + snapshot.bools["thermalOverloadReduction"] = failure->thermalOverloadReduction(); + snapshot.bools["referenceMeasurementFailed"] = failure->referenceMeasurementFailed(); + } + if (const auto mode = dt6.getOperatingMode(short_address)) { + snapshot.ints["operatingModeRaw"] = mode->raw(); + snapshot.bools["pwmModeActive"] = mode->pwmModeActive(); + snapshot.bools["amModeActive"] = mode->amModeActive(); + snapshot.bools["currentControlledOutput"] = mode->currentControlledOutput(); + snapshot.bools["highCurrentPulseModeActive"] = mode->highCurrentPulseModeActive(); + snapshot.bools["nonLogarithmicDimmingCurveActive"] = + mode->nonLogarithmicDimmingCurveActive(); + } + return snapshot; +} + +std::optional DaliDomainService::dt8SceneColorReport( + uint8_t gateway_id, int short_address, int scene) const { + const auto* channel = findChannelByGateway(gateway_id); + if (channel == nullptr || channel->dali == nullptr) { + return std::nullopt; + } + const auto report = channel->dali->dt8.getSceneColorReport(short_address, scene); + if (!report.has_value()) { + return std::nullopt; + } + auto snapshot = MakeSnapshot(gateway_id, short_address, "dt8_scene"); + snapshot.ints["scene"] = scene; + snapshot.ints["brightness"] = report->brightness; + snapshot.ints["colorType"] = report->colorTypeValue; + if (report->hasColorTemperature()) { + snapshot.ints["colorTemperature"] = report->colorTemperature.value(); + } + if (report->hasXy()) { + snapshot.number_arrays["xy"] = report->xy; + } + return snapshot; +} + +std::optional DaliDomainService::dt8PowerOnLevelColorReport( + uint8_t gateway_id, int short_address) const { + const auto* channel = findChannelByGateway(gateway_id); + if (channel == nullptr || channel->dali == nullptr) { + return std::nullopt; + } + const auto report = channel->dali->dt8.getPowerOnLevelColorReport(short_address); + if (!report.has_value()) { + return std::nullopt; + } + auto snapshot = MakeSnapshot(gateway_id, short_address, "dt8_power_on"); + snapshot.ints["level"] = report->level; + snapshot.ints["colorType"] = report->colorTypeValue; + if (report->hasColorTemperature()) { + snapshot.ints["colorTemperature"] = report->colorTemperature.value(); + } + if (report->hasXy()) { + snapshot.number_arrays["xy"] = report->xy; + } + return snapshot; +} + +std::optional DaliDomainService::dt8SystemFailureLevelColorReport( + uint8_t gateway_id, int short_address) const { + const auto* channel = findChannelByGateway(gateway_id); + if (channel == nullptr || channel->dali == nullptr) { + return std::nullopt; + } + const auto report = channel->dali->dt8.getSystemFailureLevelColorReport(short_address); + if (!report.has_value()) { + return std::nullopt; + } + auto snapshot = MakeSnapshot(gateway_id, short_address, "dt8_system_failure"); + snapshot.ints["level"] = report->level; + snapshot.ints["colorType"] = report->colorTypeValue; + if (report->hasColorTemperature()) { + snapshot.ints["colorTemperature"] = report->colorTemperature.value(); + } + if (report->hasXy()) { + snapshot.number_arrays["xy"] = report->xy; + } + return snapshot; +} + +bool DaliDomainService::storeDt8SceneSnapshot(uint8_t gateway_id, int short_address, int scene, + int brightness, + DaliDt8SceneColorMode color_mode, + int color_temperature, int red, int green, + int blue) const { + const auto* channel = findChannelByGateway(gateway_id); + return channel != nullptr && channel->dali != nullptr && + channel->dali->dt8.storeSceneSnapshot(short_address, scene, brightness, + ToDaliCppColorMode(color_mode), color_temperature, + red, green, blue); +} + +bool DaliDomainService::storeDt8PowerOnLevelSnapshot(uint8_t gateway_id, int short_address, + int level) const { + const auto* channel = findChannelByGateway(gateway_id); + return channel != nullptr && channel->dali != nullptr && + channel->dali->dt8.storePowerOnLevelSnapshot(short_address, level); +} + +bool DaliDomainService::storeDt8SystemFailureLevelSnapshot(uint8_t gateway_id, + int short_address, int level) const { + const auto* channel = findChannelByGateway(gateway_id); + return channel != nullptr && channel->dali != nullptr && + channel->dali->dt8.storeSystemFailureLevelSnapshot(short_address, level); +} + bool DaliDomainService::setBright(uint8_t gateway_id, int short_address, int brightness) const { const auto* channel = findChannelByGateway(gateway_id); return channel != nullptr && channel->dali != nullptr && diff --git a/components/gateway_bacnet/CMakeLists.txt b/components/gateway_bacnet/CMakeLists.txt new file mode 100644 index 0000000..8ac9763 --- /dev/null +++ b/components/gateway_bacnet/CMakeLists.txt @@ -0,0 +1,128 @@ +if(NOT CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) + idf_component_register( + INCLUDE_DIRS "include" + REQUIRES dali_cpp + ) + return() +endif() + +set(BACNET_STACK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../bacnet-stack") +set(BACNET_SRC_ROOT "${BACNET_STACK_ROOT}/src") +set(BACNET_ESP32_PORT "${BACNET_STACK_ROOT}/ports/esp32/src") +idf_build_get_property(IDF_PATH IDF_PATH) + +set(BACNET_CORE_SRCS + "${BACNET_SRC_ROOT}/bacnet/abort.c" + "${BACNET_SRC_ROOT}/bacnet/bacaddr.c" + "${BACNET_SRC_ROOT}/bacnet/bacapp.c" + "${BACNET_SRC_ROOT}/bacnet/bacdcode.c" + "${BACNET_SRC_ROOT}/bacnet/bacdest.c" + "${BACNET_SRC_ROOT}/bacnet/bacdevobjpropref.c" + "${BACNET_SRC_ROOT}/bacnet/bacerror.c" + "${BACNET_SRC_ROOT}/bacnet/bacint.c" + "${BACNET_SRC_ROOT}/bacnet/bacprop.c" + "${BACNET_SRC_ROOT}/bacnet/bacpropstates.c" + "${BACNET_SRC_ROOT}/bacnet/bacreal.c" + "${BACNET_SRC_ROOT}/bacnet/bacstr.c" + "${BACNET_SRC_ROOT}/bacnet/bactext.c" + "${BACNET_SRC_ROOT}/bacnet/bactimevalue.c" + "${BACNET_SRC_ROOT}/bacnet/calendar_entry.c" + "${BACNET_SRC_ROOT}/bacnet/cov.c" + "${BACNET_SRC_ROOT}/bacnet/datetime.c" + "${BACNET_SRC_ROOT}/bacnet/dcc.c" + "${BACNET_SRC_ROOT}/bacnet/iam.c" + "${BACNET_SRC_ROOT}/bacnet/ihave.c" + "${BACNET_SRC_ROOT}/bacnet/list_element.c" + "${BACNET_SRC_ROOT}/bacnet/memcopy.c" + "${BACNET_SRC_ROOT}/bacnet/npdu.c" + "${BACNET_SRC_ROOT}/bacnet/proplist.c" + "${BACNET_SRC_ROOT}/bacnet/reject.c" + "${BACNET_SRC_ROOT}/bacnet/rp.c" + "${BACNET_SRC_ROOT}/bacnet/rpm.c" + "${BACNET_SRC_ROOT}/bacnet/timestamp.c" + "${BACNET_SRC_ROOT}/bacnet/whohas.c" + "${BACNET_SRC_ROOT}/bacnet/whois.c" + "${BACNET_SRC_ROOT}/bacnet/wp.c" +) + +set(BACNET_BASIC_SRCS + "${BACNET_SRC_ROOT}/bacnet/basic/binding/address.c" + "${BACNET_SRC_ROOT}/bacnet/basic/npdu/h_npdu.c" + "${BACNET_SRC_ROOT}/bacnet/basic/npdu/s_router.c" + "${BACNET_SRC_ROOT}/bacnet/basic/object/ao.c" + "${BACNET_SRC_ROOT}/bacnet/basic/object/av.c" + "${BACNET_SRC_ROOT}/bacnet/basic/object/bo.c" + "${BACNET_SRC_ROOT}/bacnet/basic/object/bv.c" + "${BACNET_SRC_ROOT}/bacnet/basic/object/device.c" + "${BACNET_SRC_ROOT}/bacnet/basic/object/msv.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/h_apdu.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/h_cov.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/h_dcc.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/h_noserv.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/h_rp.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/h_rpm.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/h_whohas.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/h_whois.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/h_wp.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/s_abort.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/s_error.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/s_iam.c" + "${BACNET_SRC_ROOT}/bacnet/basic/service/s_ihave.c" + "${BACNET_SRC_ROOT}/bacnet/basic/sys/bigend.c" + "${BACNET_SRC_ROOT}/bacnet/basic/sys/days.c" + "${BACNET_SRC_ROOT}/bacnet/basic/sys/datetime_mstimer.c" + "${BACNET_SRC_ROOT}/bacnet/basic/sys/debug.c" + "${BACNET_SRC_ROOT}/bacnet/basic/sys/dst.c" + "${BACNET_SRC_ROOT}/bacnet/basic/sys/keylist.c" + "${BACNET_SRC_ROOT}/bacnet/basic/sys/linear.c" + "${BACNET_SRC_ROOT}/bacnet/basic/sys/mstimer.c" + "${BACNET_SRC_ROOT}/bacnet/basic/tsm/tsm.c" +) + +set(BACNET_PORT_SRCS + "${BACNET_ESP32_PORT}/bip.c" + "${BACNET_ESP32_PORT}/mstimer_init.c" + "${BACNET_ESP32_PORT}/bip_init.c" + "${BACNET_ESP32_PORT}/bvlc.c" +) + +idf_component_register( + SRCS + "src/gateway_bacnet.cpp" + "src/gateway_bacnet_stack_port.c" + "src/bip_socket_lwip.cpp" + ${BACNET_CORE_SRCS} + ${BACNET_BASIC_SRCS} + ${BACNET_PORT_SRCS} + INCLUDE_DIRS + "include" + "${BACNET_ESP32_PORT}" + "${BACNET_SRC_ROOT}" + PRIV_INCLUDE_DIRS + "${IDF_PATH}/components/esp_netif/include" + REQUIRES dali_cpp esp_netif freertos + PRIV_REQUIRES log lwip +) + +target_compile_definitions(${COMPONENT_LIB} PRIVATE + BACDL_BIP=1 + BACAPP_MINIMAL=1 + BACNET_GATEWAY_EXTERNAL_OBJECT_TABLE=1 + BACNET_PROPERTY_LISTS=1 + BACNET_PROTOCOL_REVISION=16 + BACNET_STACK_DEPRECATED_DISABLE=1 + BBMD_ENABLED=0 + BBMD_CLIENT_ENABLED=0 + MAX_ADDRESS_CACHE=8 + MAX_APDU=480 + MAX_TSM_TRANSACTIONS=8 + PRINT_ENABLED=0 +) + +target_compile_options(${COMPONENT_LIB} PRIVATE + $<$:-Wno-missing-field-initializers> + $<$:-Wno-old-style-declaration> + $<$:-Wno-unused-function> +) + +set_property(TARGET ${COMPONENT_LIB} PROPERTY CXX_STANDARD 17) \ No newline at end of file diff --git a/components/gateway_bacnet/include/gateway_bacnet.hpp b/components/gateway_bacnet/include/gateway_bacnet.hpp new file mode 100644 index 0000000..48a8457 --- /dev/null +++ b/components/gateway_bacnet/include/gateway_bacnet.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include "bridge_model.hpp" +#include "model_value.hpp" + +#include "esp_err.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include +#include +#include +#include + +namespace gateway { + +struct GatewayBacnetServerConfig { + uint32_t device_instance{4194303}; + std::string device_name{"DALI Gateway"}; + std::string local_address; + uint16_t udp_port{47808}; + uint32_t task_stack_size{8192}; + UBaseType_t task_priority{5}; +}; + +struct GatewayBacnetObjectBinding { + uint8_t gateway_id{0}; + std::string model_id; + std::string name; + BridgeObjectType object_type{BridgeObjectType::unknown}; + uint32_t object_instance{0}; + std::string property{"presentValue"}; +}; + +struct GatewayBacnetServerStatus { + bool started{false}; + uint32_t device_instance{0}; + uint16_t udp_port{0}; + size_t channel_count{0}; + size_t object_count{0}; +}; + +using GatewayBacnetWriteCallback = + std::function; + +class GatewayBacnetServer { + public: + static GatewayBacnetServer& instance(); + + esp_err_t registerChannel(uint8_t gateway_id, const GatewayBacnetServerConfig& config, + std::vector bindings, + GatewayBacnetWriteCallback write_callback); + GatewayBacnetServerStatus status() const; + bool configCompatible(const GatewayBacnetServerConfig& config) const; + bool handleWrite(BridgeObjectType object_type, uint32_t object_instance, + const DaliValue& value); + + private: + GatewayBacnetServer(); + ~GatewayBacnetServer(); + + GatewayBacnetServer(const GatewayBacnetServer&) = delete; + GatewayBacnetServer& operator=(const GatewayBacnetServer&) = delete; + + struct ChannelRegistration; + struct RuntimeBinding; + + esp_err_t startStackLocked(const GatewayBacnetServerConfig& config); + esp_err_t rebuildObjectsLocked(); + + static void TaskEntry(void* arg); + void taskLoop(); + + GatewayBacnetServerConfig active_config_; + std::vector channels_; + std::vector runtime_bindings_; + mutable SemaphoreHandle_t lock_{nullptr}; + TaskHandle_t task_handle_{nullptr}; + bool started_{false}; +}; + +} // namespace gateway \ No newline at end of file diff --git a/components/gateway_bacnet/include/gateway_bacnet_stack_port.h b/components/gateway_bacnet/include/gateway_bacnet_stack_port.h new file mode 100644 index 0000000..955ae41 --- /dev/null +++ b/components/gateway_bacnet/include/gateway_bacnet_stack_port.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum gateway_bacnet_object_kind { + GW_BACNET_OBJECT_UNKNOWN = 0, + GW_BACNET_OBJECT_ANALOG_VALUE, + GW_BACNET_OBJECT_ANALOG_OUTPUT, + GW_BACNET_OBJECT_BINARY_VALUE, + GW_BACNET_OBJECT_BINARY_OUTPUT, + GW_BACNET_OBJECT_MULTI_STATE_VALUE, +} gateway_bacnet_object_kind_t; + +typedef enum gateway_bacnet_write_value_kind { + GW_BACNET_WRITE_VALUE_REAL = 1, + GW_BACNET_WRITE_VALUE_BOOLEAN = 2, + GW_BACNET_WRITE_VALUE_UNSIGNED = 3, +} gateway_bacnet_write_value_kind_t; + +typedef struct gateway_bacnet_write_value { + gateway_bacnet_write_value_kind_t kind; + double real_value; + bool boolean_value; + uint32_t unsigned_value; +} gateway_bacnet_write_value_t; + +typedef void (*gateway_bacnet_stack_write_callback_t)( + gateway_bacnet_object_kind_t object_kind, + uint32_t object_instance, + const gateway_bacnet_write_value_t* value, + void* context); + +bool gateway_bacnet_stack_start( + uint32_t device_instance, + const char* device_name, + uint16_t udp_port, + gateway_bacnet_stack_write_callback_t write_callback, + void* callback_context); + +void gateway_bacnet_stack_cleanup(void); + +bool gateway_bacnet_stack_upsert_object( + gateway_bacnet_object_kind_t object_kind, + uint32_t object_instance, + const char* object_name, + const char* description); + +void gateway_bacnet_stack_send_i_am(void); +void gateway_bacnet_stack_poll(uint16_t elapsed_ms); + +#ifdef __cplusplus +} +#endif \ No newline at end of file diff --git a/components/gateway_bacnet/src/bip_socket_lwip.cpp b/components/gateway_bacnet/src/bip_socket_lwip.cpp new file mode 100644 index 0000000..89f86f9 --- /dev/null +++ b/components/gateway_bacnet/src/bip_socket_lwip.cpp @@ -0,0 +1,115 @@ +#include "bip.h" + +#include "esp_log.h" +#include "esp_netif.h" +#include "lwip/inet.h" +#include "lwip/sockets.h" + +#include +#include +#include + +namespace { + +constexpr const char* kTag = "gateway_bacnet_socket"; +int s_socket = -1; + +bool NetifInfo(const char* ifkey, uint8_t* local_addr, uint8_t* netmask) { + esp_netif_t* netif = esp_netif_get_handle_from_ifkey(ifkey); + if (netif == nullptr) { + return false; + } + esp_netif_ip_info_t ip_info = {}; + if (esp_netif_get_ip_info(netif, &ip_info) != ESP_OK || ip_info.ip.addr == 0) { + return false; + } + std::memcpy(local_addr, &ip_info.ip.addr, 4); + std::memcpy(netmask, &ip_info.netmask.addr, 4); + return true; +} + +} // namespace + +extern "C" bool bip_socket_init(uint16_t port) { + bip_socket_cleanup(); + + s_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + if (s_socket < 0) { + ESP_LOGE(kTag, "failed to create BACnet/IP UDP socket errno=%d", errno); + return false; + } + + int enabled = 1; + setsockopt(s_socket, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)); + setsockopt(s_socket, SOL_SOCKET, SO_BROADCAST, &enabled, sizeof(enabled)); + + sockaddr_in local = {}; + local.sin_family = AF_INET; + local.sin_addr.s_addr = htonl(INADDR_ANY); + local.sin_port = htons(port); + if (bind(s_socket, reinterpret_cast(&local), sizeof(local)) != 0) { + ESP_LOGE(kTag, "failed to bind BACnet/IP UDP port %u errno=%d", port, errno); + bip_socket_cleanup(); + return false; + } + + timeval timeout = {}; + timeout.tv_sec = 0; + timeout.tv_usec = 1000; + setsockopt(s_socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + + ESP_LOGI(kTag, "BACnet/IP UDP socket bound on port %u", port); + return true; +} + +extern "C" int bip_socket_send(const uint8_t* dest_addr, uint16_t dest_port, + const uint8_t* mtu, uint16_t mtu_len) { + if (s_socket < 0 || dest_addr == nullptr || mtu == nullptr || mtu_len == 0) { + return -1; + } + + sockaddr_in dest = {}; + dest.sin_family = AF_INET; + dest.sin_port = htons(dest_port); + std::memcpy(&dest.sin_addr.s_addr, dest_addr, 4); + + const int sent = sendto(s_socket, mtu, mtu_len, 0, reinterpret_cast(&dest), + sizeof(dest)); + return sent == mtu_len ? sent : -1; +} + +extern "C" int bip_socket_receive(uint8_t* buf, uint16_t buf_len, uint8_t* src_addr, + uint16_t* src_port) { + if (s_socket < 0 || buf == nullptr || src_addr == nullptr || src_port == nullptr || + buf_len == 0) { + return 0; + } + + sockaddr_in source = {}; + socklen_t source_len = sizeof(source); + const int received = recvfrom(s_socket, buf, buf_len, 0, reinterpret_cast(&source), + &source_len); + if (received <= 0) { + return 0; + } + + std::memcpy(src_addr, &source.sin_addr.s_addr, 4); + *src_port = ntohs(source.sin_port); + return received; +} + +extern "C" void bip_socket_cleanup(void) { + if (s_socket >= 0) { + close(s_socket); + s_socket = -1; + } +} + +extern "C" bool bip_get_local_network_info(uint8_t* local_addr, uint8_t* netmask) { + if (local_addr == nullptr || netmask == nullptr) { + return false; + } + return NetifInfo("WIFI_STA_DEF", local_addr, netmask) || + NetifInfo("ETH_DEF", local_addr, netmask) || + NetifInfo("WIFI_AP_DEF", local_addr, netmask); +} \ No newline at end of file diff --git a/components/gateway_bacnet/src/gateway_bacnet.cpp b/components/gateway_bacnet/src/gateway_bacnet.cpp new file mode 100644 index 0000000..ee0fe76 --- /dev/null +++ b/components/gateway_bacnet/src/gateway_bacnet.cpp @@ -0,0 +1,330 @@ +#include "gateway_bacnet.hpp" + +#include "gateway_bacnet_stack_port.h" + +#include "esp_log.h" +#include "freertos/semphr.h" + +#include +#include +#include +#include +#include +#include + +namespace gateway { + +namespace { + +constexpr const char* kTag = "gateway_bacnet"; +constexpr TickType_t kPollDelayTicks = pdMS_TO_TICKS(10); + +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_; +}; + +constexpr uint32_t kMaxBacnetInstance = 4194303; +GatewayBacnetServer* g_server = nullptr; + +gateway_bacnet_object_kind_t ToBacnetKind(BridgeObjectType type) { + switch (type) { + case BridgeObjectType::analogValue: + return GW_BACNET_OBJECT_ANALOG_VALUE; + case BridgeObjectType::analogOutput: + return GW_BACNET_OBJECT_ANALOG_OUTPUT; + case BridgeObjectType::binaryValue: + return GW_BACNET_OBJECT_BINARY_VALUE; + case BridgeObjectType::binaryOutput: + return GW_BACNET_OBJECT_BINARY_OUTPUT; + case BridgeObjectType::multiStateValue: + return GW_BACNET_OBJECT_MULTI_STATE_VALUE; + default: + return GW_BACNET_OBJECT_UNKNOWN; + } +} + +BridgeObjectType FromBacnetKind(gateway_bacnet_object_kind_t kind) { + switch (kind) { + case GW_BACNET_OBJECT_ANALOG_VALUE: + return BridgeObjectType::analogValue; + case GW_BACNET_OBJECT_ANALOG_OUTPUT: + return BridgeObjectType::analogOutput; + case GW_BACNET_OBJECT_BINARY_VALUE: + return BridgeObjectType::binaryValue; + case GW_BACNET_OBJECT_BINARY_OUTPUT: + return BridgeObjectType::binaryOutput; + case GW_BACNET_OBJECT_MULTI_STATE_VALUE: + return BridgeObjectType::multiStateValue; + default: + return BridgeObjectType::unknown; + } +} + +bool IsSupportedObjectType(BridgeObjectType type) { + return ToBacnetKind(type) != GW_BACNET_OBJECT_UNKNOWN; +} + +std::string ObjectName(const GatewayBacnetObjectBinding& binding) { + if (!binding.name.empty()) { + return binding.name; + } + if (!binding.model_id.empty()) { + return "DALI " + binding.model_id; + } + return "DALI BACnet " + std::to_string(binding.object_instance); +} + +DaliValue StackWriteValueToDali(const gateway_bacnet_write_value_t& value) { + switch (value.kind) { + case GW_BACNET_WRITE_VALUE_REAL: + return DaliValue(value.real_value); + case GW_BACNET_WRITE_VALUE_BOOLEAN: + return DaliValue(value.boolean_value); + case GW_BACNET_WRITE_VALUE_UNSIGNED: + return DaliValue(static_cast(value.unsigned_value)); + default: + return DaliValue(); + } +} + +void HandleStackWrite(gateway_bacnet_object_kind_t object_kind, uint32_t object_instance, + const gateway_bacnet_write_value_t* value, void*) { + if (g_server == nullptr || value == nullptr) { + return; + } + g_server->handleWrite(FromBacnetKind(object_kind), object_instance, StackWriteValueToDali(*value)); +} + +} // namespace + +struct GatewayBacnetServer::ChannelRegistration { + uint8_t gateway_id{0}; + GatewayBacnetServerConfig config; + std::vector bindings; + GatewayBacnetWriteCallback write_callback; +}; + +struct GatewayBacnetServer::RuntimeBinding { + uint8_t gateway_id{0}; + BridgeObjectType object_type{BridgeObjectType::unknown}; + uint32_t object_instance{0}; + std::string model_id; + std::string property{"presentValue"}; + GatewayBacnetWriteCallback write_callback; +}; + +GatewayBacnetServer& GatewayBacnetServer::instance() { + static GatewayBacnetServer server; + return server; +} + +GatewayBacnetServer::GatewayBacnetServer() : lock_(xSemaphoreCreateRecursiveMutex()) { + g_server = this; +} + +GatewayBacnetServer::~GatewayBacnetServer() { + if (lock_ != nullptr) { + vSemaphoreDelete(lock_); + lock_ = nullptr; + } +} + +bool GatewayBacnetServer::configCompatible(const GatewayBacnetServerConfig& config) const { + LockGuard guard(lock_); + return !started_ || (active_config_.udp_port == config.udp_port && + active_config_.device_instance == config.device_instance); +} + +GatewayBacnetServerStatus GatewayBacnetServer::status() const { + LockGuard guard(lock_); + return GatewayBacnetServerStatus{started_, + active_config_.device_instance, + active_config_.udp_port, + channels_.size(), + runtime_bindings_.size()}; +} + +esp_err_t GatewayBacnetServer::registerChannel( + uint8_t gateway_id, const GatewayBacnetServerConfig& config, + std::vector bindings, + GatewayBacnetWriteCallback write_callback) { + if (write_callback == nullptr) { + return ESP_ERR_INVALID_ARG; + } + + bindings.erase(std::remove_if(bindings.begin(), bindings.end(), [](const auto& binding) { + return !IsSupportedObjectType(binding.object_type) || + binding.object_instance > kMaxBacnetInstance; + }), + bindings.end()); + if (bindings.empty()) { + return ESP_ERR_NOT_FOUND; + } + + LockGuard guard(lock_); + if (started_ && !configCompatible(config)) { + return ESP_ERR_INVALID_STATE; + } + + auto channel = std::find_if(channels_.begin(), channels_.end(), [gateway_id](const auto& item) { + return item.gateway_id == gateway_id; + }); + ChannelRegistration registration{gateway_id, config, std::move(bindings), + std::move(write_callback)}; + if (channel == channels_.end()) { + channels_.push_back(std::move(registration)); + } else { + *channel = std::move(registration); + } + + esp_err_t err = startStackLocked(config); + if (err != ESP_OK) { + return err; + } + return rebuildObjectsLocked(); +} + +esp_err_t GatewayBacnetServer::startStackLocked(const GatewayBacnetServerConfig& config) { + if (started_) { + return ESP_OK; + } + + active_config_ = config; + if (active_config_.device_name.empty()) { + active_config_.device_name = "DALI Gateway"; + } + if (active_config_.udp_port == 0) { + active_config_.udp_port = 47808; + } + if (active_config_.task_stack_size < 6144) { + active_config_.task_stack_size = 6144; + } + + if (!gateway_bacnet_stack_start(active_config_.device_instance, active_config_.device_name.c_str(), + active_config_.udp_port, HandleStackWrite, this)) { + ESP_LOGE(kTag, "failed to initialize BACnet/IP port %u", active_config_.udp_port); + return ESP_FAIL; + } + + const BaseType_t created = xTaskCreate(&GatewayBacnetServer::TaskEntry, "gw_bacnet_ip", + active_config_.task_stack_size, this, + active_config_.task_priority, &task_handle_); + if (created != pdPASS) { + task_handle_ = nullptr; + gateway_bacnet_stack_cleanup(); + return ESP_ERR_NO_MEM; + } + + started_ = true; + gateway_bacnet_stack_send_i_am(); + ESP_LOGI(kTag, "BACnet/IP server started device=%lu port=%u", + static_cast(active_config_.device_instance), active_config_.udp_port); + return ESP_OK; +} + +esp_err_t GatewayBacnetServer::rebuildObjectsLocked() { + runtime_bindings_.clear(); + std::set> used_objects; + + for (const auto& channel : channels_) { + for (const auto& binding : channel.bindings) { + const auto key = std::make_pair(binding.object_type, binding.object_instance); + if (used_objects.find(key) != used_objects.end()) { + ESP_LOGE(kTag, "duplicate BACnet object type=%d instance=%lu", + static_cast(binding.object_type), + static_cast(binding.object_instance)); + return ESP_ERR_INVALID_STATE; + } + used_objects.insert(key); + + 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())) { + return ESP_FAIL; + } + runtime_bindings_.push_back(RuntimeBinding{channel.gateway_id, + binding.object_type, + binding.object_instance, + binding.model_id, + binding.property.empty() ? "presentValue" + : binding.property, + channel.write_callback}); + } + } + + ESP_LOGI(kTag, "BACnet/IP object table updated objects=%u", + static_cast(runtime_bindings_.size())); + return ESP_OK; +} + +bool GatewayBacnetServer::handleWrite(BridgeObjectType object_type, uint32_t object_instance, + const DaliValue& value) { + GatewayBacnetWriteCallback callback; + std::string property; + std::string model_id; + uint8_t gateway_id = 0; + { + LockGuard guard(lock_); + const auto binding = std::find_if(runtime_bindings_.begin(), runtime_bindings_.end(), + [object_type, object_instance](const auto& item) { + return item.object_type == object_type && + item.object_instance == object_instance; + }); + if (binding == runtime_bindings_.end()) { + ESP_LOGW(kTag, "write for unmapped BACnet object type=%d instance=%lu", + static_cast(object_type), static_cast(object_instance)); + return false; + } + callback = binding->write_callback; + property = binding->property; + model_id = binding->model_id; + gateway_id = binding->gateway_id; + } + + const bool ok = callback != nullptr && callback(object_type, object_instance, property, value); + if (!ok) { + ESP_LOGW(kTag, "gateway=%u BACnet write failed model=%s object=%lu", + gateway_id, model_id.c_str(), static_cast(object_instance)); + } + return ok; +} + +void GatewayBacnetServer::TaskEntry(void* arg) { + static_cast(arg)->taskLoop(); +} + +void GatewayBacnetServer::taskLoop() { + TickType_t last_timer = xTaskGetTickCount(); + + while (true) { + const TickType_t now = xTaskGetTickCount(); + const TickType_t elapsed = now - last_timer; + uint16_t elapsed_ms = 0; + if (elapsed >= pdMS_TO_TICKS(1000)) { + elapsed_ms = static_cast(elapsed * portTICK_PERIOD_MS); + last_timer = now; + } + { + LockGuard guard(lock_); + gateway_bacnet_stack_poll(elapsed_ms); + } + vTaskDelay(kPollDelayTicks); + } +} + +} // namespace gateway \ No newline at end of file diff --git a/components/gateway_bacnet/src/gateway_bacnet_stack_port.c b/components/gateway_bacnet/src/gateway_bacnet_stack_port.c new file mode 100644 index 0000000..2d87b51 --- /dev/null +++ b/components/gateway_bacnet/src/gateway_bacnet_stack_port.c @@ -0,0 +1,288 @@ +#include "gateway_bacnet_stack_port.h" + +#include +#include + +#include "bacnet/apdu.h" +#include "bacnet/basic/binding/address.h" +#include "bacnet/basic/object/ao.h" +#include "bacnet/basic/object/av.h" +#include "bacnet/basic/object/bo.h" +#include "bacnet/basic/object/bv.h" +#include "bacnet/basic/object/device.h" +#include "bacnet/basic/object/msv.h" +#include "bacnet/basic/service/h_apdu.h" +#include "bacnet/basic/services.h" +#include "bacnet/basic/tsm/tsm.h" +#include "bacnet/bacdef.h" +#include "bacnet/npdu.h" +#include "bip.h" + +static gateway_bacnet_stack_write_callback_t Write_Callback; +static void* Write_Callback_Context; +static uint8_t Rx_Buffer[BIP_MPDU_MAX]; +static const char Multistate_Value_States[] = + "State 1\0" + "State 2\0" + "State 3\0" + "State 4\0" + "State 5\0" + "State 6\0" + "State 7\0" + "State 8\0" + "State 9\0" + "State 10\0" + "State 11\0" + "State 12\0" + "State 13\0" + "State 14\0" + "State 15\0" + "State 16\0"; + +static void notify_write_real( + gateway_bacnet_object_kind_t object_kind, uint32_t object_instance, double value) +{ + if (Write_Callback) { + gateway_bacnet_write_value_t write_value = { + .kind = GW_BACNET_WRITE_VALUE_REAL, + .real_value = value, + .boolean_value = false, + .unsigned_value = 0, + }; + Write_Callback(object_kind, object_instance, &write_value, Write_Callback_Context); + } +} + +static void notify_write_boolean( + gateway_bacnet_object_kind_t object_kind, uint32_t object_instance, bool value) +{ + if (Write_Callback) { + gateway_bacnet_write_value_t write_value = { + .kind = GW_BACNET_WRITE_VALUE_BOOLEAN, + .real_value = 0.0, + .boolean_value = value, + .unsigned_value = 0, + }; + Write_Callback(object_kind, object_instance, &write_value, Write_Callback_Context); + } +} + +static void notify_write_unsigned( + gateway_bacnet_object_kind_t object_kind, uint32_t object_instance, uint32_t value) +{ + if (Write_Callback) { + gateway_bacnet_write_value_t write_value = { + .kind = GW_BACNET_WRITE_VALUE_UNSIGNED, + .real_value = 0.0, + .boolean_value = false, + .unsigned_value = value, + }; + Write_Callback(object_kind, object_instance, &write_value, Write_Callback_Context); + } +} + +static void analog_value_write(uint32_t object_instance, float old_value, float value) +{ + (void)old_value; + notify_write_real(GW_BACNET_OBJECT_ANALOG_VALUE, object_instance, value); +} + +static void analog_output_write(uint32_t object_instance, float old_value, float value) +{ + (void)old_value; + notify_write_real(GW_BACNET_OBJECT_ANALOG_OUTPUT, object_instance, value); +} + +static void binary_value_write( + uint32_t object_instance, BACNET_BINARY_PV old_value, BACNET_BINARY_PV value) +{ + (void)old_value; + notify_write_boolean(GW_BACNET_OBJECT_BINARY_VALUE, object_instance, value == BINARY_ACTIVE); +} + +static void binary_output_write( + uint32_t object_instance, BACNET_BINARY_PV old_value, BACNET_BINARY_PV value) +{ + (void)old_value; + notify_write_boolean(GW_BACNET_OBJECT_BINARY_OUTPUT, object_instance, value == BINARY_ACTIVE); +} + +static void multistate_value_write(uint32_t object_instance, uint32_t old_value, uint32_t value) +{ + (void)old_value; + notify_write_unsigned(GW_BACNET_OBJECT_MULTI_STATE_VALUE, object_instance, value); +} + +static object_functions_t Object_Table[] = { + { OBJECT_DEVICE, NULL, Device_Count, Device_Index_To_Instance, + Device_Valid_Object_Instance_Number, Device_Object_Name, Device_Read_Property_Local, + Device_Write_Property_Local, Device_Property_Lists, DeviceGetRRInfo, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, NULL, NULL, Device_Writable_Property_List }, + { OBJECT_ANALOG_VALUE, Analog_Value_Init, Analog_Value_Count, + Analog_Value_Index_To_Instance, Analog_Value_Valid_Instance, Analog_Value_Object_Name, + Analog_Value_Read_Property, Analog_Value_Write_Property, Analog_Value_Property_Lists, + NULL, NULL, Analog_Value_Encode_Value_List, Analog_Value_Change_Of_Value, + Analog_Value_Change_Of_Value_Clear, NULL, NULL, NULL, Analog_Value_Create, + Analog_Value_Delete, NULL, Analog_Value_Writable_Property_List }, + { OBJECT_ANALOG_OUTPUT, Analog_Output_Init, Analog_Output_Count, + Analog_Output_Index_To_Instance, Analog_Output_Valid_Instance, Analog_Output_Object_Name, + Analog_Output_Read_Property, Analog_Output_Write_Property, Analog_Output_Property_Lists, + NULL, NULL, Analog_Output_Encode_Value_List, Analog_Output_Change_Of_Value, + Analog_Output_Change_Of_Value_Clear, NULL, NULL, NULL, Analog_Output_Create, + Analog_Output_Delete, NULL, Analog_Output_Writable_Property_List }, + { OBJECT_BINARY_VALUE, Binary_Value_Init, Binary_Value_Count, + Binary_Value_Index_To_Instance, Binary_Value_Valid_Instance, Binary_Value_Object_Name, + Binary_Value_Read_Property, Binary_Value_Write_Property, Binary_Value_Property_Lists, + NULL, NULL, Binary_Value_Encode_Value_List, Binary_Value_Change_Of_Value, + Binary_Value_Change_Of_Value_Clear, NULL, NULL, NULL, Binary_Value_Create, + Binary_Value_Delete, NULL, Binary_Value_Writable_Property_List }, + { OBJECT_BINARY_OUTPUT, Binary_Output_Init, Binary_Output_Count, + Binary_Output_Index_To_Instance, Binary_Output_Valid_Instance, Binary_Output_Object_Name, + Binary_Output_Read_Property, Binary_Output_Write_Property, Binary_Output_Property_Lists, + NULL, NULL, Binary_Output_Encode_Value_List, Binary_Output_Change_Of_Value, + Binary_Output_Change_Of_Value_Clear, NULL, NULL, NULL, Binary_Output_Create, + Binary_Output_Delete, NULL, Binary_Output_Writable_Property_List }, + { OBJECT_MULTI_STATE_VALUE, Multistate_Value_Init, Multistate_Value_Count, + Multistate_Value_Index_To_Instance, Multistate_Value_Valid_Instance, + Multistate_Value_Object_Name, Multistate_Value_Read_Property, + Multistate_Value_Write_Property, Multistate_Value_Property_Lists, NULL, NULL, + Multistate_Value_Encode_Value_List, Multistate_Value_Change_Of_Value, + Multistate_Value_Change_Of_Value_Clear, NULL, NULL, NULL, Multistate_Value_Create, + Multistate_Value_Delete, NULL, Multistate_Value_Writable_Property_List }, + { MAX_BACNET_OBJECT_TYPE, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL }, +}; + +bool gateway_bacnet_stack_start( + uint32_t device_instance, + const char* device_name, + uint16_t udp_port, + gateway_bacnet_stack_write_callback_t write_callback, + void* callback_context) +{ + if (!device_name || device_name[0] == '\0') { + device_name = "DALI Gateway"; + } + if (udp_port == 0) { + udp_port = 47808; + } + + Write_Callback = write_callback; + Write_Callback_Context = callback_context; + + address_init(); + Device_Set_Object_Instance_Number(device_instance); + Device_Init(Object_Table); + Device_Object_Name_ANSI_Init(device_name); + Device_Set_Vendor_Name("TonyCloud", strlen("TonyCloud")); + Device_Set_Vendor_Identifier(260); + Device_Set_Model_Name("DALI Gateway", strlen("DALI Gateway")); + Device_Set_Description("DALI BACnet/IP bridge", strlen("DALI BACnet/IP bridge")); + + Analog_Value_Write_Present_Value_Callback_Set(analog_value_write); + Analog_Output_Write_Present_Value_Callback_Set(analog_output_write); + Binary_Value_Write_Present_Value_Callback_Set(binary_value_write); + Binary_Output_Write_Present_Value_Callback_Set(binary_output_write); + Multistate_Value_Write_Present_Value_Callback_Set(multistate_value_write); + + apdu_set_unrecognized_service_handler_handler(handler_unrecognized_service); + apdu_set_unconfirmed_handler(SERVICE_UNCONFIRMED_WHO_IS, handler_who_is); + apdu_set_unconfirmed_handler(SERVICE_UNCONFIRMED_WHO_HAS, handler_who_has); + apdu_set_confirmed_handler(SERVICE_CONFIRMED_READ_PROPERTY, handler_read_property); + apdu_set_confirmed_handler( + SERVICE_CONFIRMED_READ_PROP_MULTIPLE, handler_read_property_multiple); + apdu_set_confirmed_handler(SERVICE_CONFIRMED_WRITE_PROPERTY, handler_write_property); + apdu_set_confirmed_handler( + SERVICE_CONFIRMED_DEVICE_COMMUNICATION_CONTROL, handler_device_communication_control); + + return bip_init(udp_port); +} + +void gateway_bacnet_stack_cleanup(void) +{ + bip_cleanup(); + Write_Callback = NULL; + Write_Callback_Context = NULL; +} + +bool gateway_bacnet_stack_upsert_object( + gateway_bacnet_object_kind_t object_kind, + uint32_t object_instance, + const char* object_name, + const char* description) +{ + if (!object_name || object_name[0] == '\0') { + object_name = "DALI BACnet Object"; + } + if (!description) { + description = ""; + } + + switch (object_kind) { + case GW_BACNET_OBJECT_ANALOG_VALUE: + if (!Analog_Value_Valid_Instance(object_instance)) { + Analog_Value_Create(object_instance); + } + Analog_Value_Name_Set(object_instance, object_name); + 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); + return true; + case GW_BACNET_OBJECT_ANALOG_OUTPUT: + if (!Analog_Output_Valid_Instance(object_instance)) { + Analog_Output_Create(object_instance); + } + Analog_Output_Name_Set(object_instance, object_name); + 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); + return true; + case GW_BACNET_OBJECT_BINARY_VALUE: + if (!Binary_Value_Valid_Instance(object_instance)) { + Binary_Value_Create(object_instance); + } + Binary_Value_Name_Set(object_instance, object_name); + Binary_Value_Description_Set(object_instance, description); + Binary_Value_Write_Enable(object_instance); + Binary_Value_Present_Value_Set(object_instance, BINARY_INACTIVE); + return true; + case GW_BACNET_OBJECT_BINARY_OUTPUT: + if (!Binary_Output_Valid_Instance(object_instance)) { + Binary_Output_Create(object_instance); + } + 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); + return true; + case GW_BACNET_OBJECT_MULTI_STATE_VALUE: + if (!Multistate_Value_Valid_Instance(object_instance)) { + Multistate_Value_Create(object_instance); + } + Multistate_Value_Name_Set(object_instance, object_name); + Multistate_Value_Description_Set(object_instance, description); + 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); + return true; + default: + return false; + } +} + +void gateway_bacnet_stack_send_i_am(void) +{ + Send_I_Am(&Handler_Transmit_Buffer[0]); +} + +void gateway_bacnet_stack_poll(uint16_t elapsed_ms) +{ + BACNET_ADDRESS src = { 0 }; + uint16_t pdu_len = bip_receive(&src, Rx_Buffer, sizeof(Rx_Buffer), 0); + if (pdu_len > 0) { + npdu_handler(&src, Rx_Buffer, pdu_len); + } + if (elapsed_ms > 0) { + tsm_timer_milliseconds(elapsed_ms); + Device_Timer(elapsed_ms); + } +} \ No newline at end of file diff --git a/components/gateway_bridge/CMakeLists.txt b/components/gateway_bridge/CMakeLists.txt new file mode 100644 index 0000000..162524d --- /dev/null +++ b/components/gateway_bridge/CMakeLists.txt @@ -0,0 +1,18 @@ +set(GATEWAY_BRIDGE_REQUIRES + dali_domain + dali_cpp + espressif__cjson + freertos + log + lwip + nvs_flash +) + +idf_component_register( + SRCS "src/gateway_bridge.cpp" + INCLUDE_DIRS "include" + REQUIRES ${GATEWAY_BRIDGE_REQUIRES} + PRIV_REQUIRES gateway_bacnet +) + +set_property(TARGET ${COMPONENT_LIB} PROPERTY CXX_STANDARD 17) diff --git a/components/gateway_bridge/include/gateway_bridge.hpp b/components/gateway_bridge/include/gateway_bridge.hpp new file mode 100644 index 0000000..e4b0f97 --- /dev/null +++ b/components/gateway_bridge/include/gateway_bridge.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include + +#include "esp_err.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +namespace gateway { + +class DaliDomainService; + +struct GatewayBridgeServiceConfig { + bool bridge_enabled{true}; + bool modbus_enabled{true}; + bool modbus_startup_enabled{false}; + bool bacnet_enabled{false}; + bool bacnet_startup_enabled{false}; + bool cloud_enabled{true}; + bool cloud_startup_enabled{false}; + uint32_t modbus_task_stack_size{6144}; + UBaseType_t modbus_task_priority{4}; + uint32_t bacnet_task_stack_size{8192}; + UBaseType_t bacnet_task_priority{5}; +}; + +struct GatewayBridgeHttpResponse { + esp_err_t err{ESP_OK}; + std::string body; +}; + +class GatewayBridgeService { + public: + GatewayBridgeService(DaliDomainService& dali_domain, + GatewayBridgeServiceConfig config = {}); + ~GatewayBridgeService(); + + esp_err_t start(); + + GatewayBridgeHttpResponse handleGet(const std::string& action, int gateway_id = -1, + const std::string& query = {}) const; + GatewayBridgeHttpResponse handlePost(const std::string& action, int gateway_id, + const std::string& body); + + private: + struct ChannelRuntime; + + ChannelRuntime* findRuntime(uint8_t gateway_id); + const ChannelRuntime* findRuntime(uint8_t gateway_id) const; + + DaliDomainService& dali_domain_; + GatewayBridgeServiceConfig config_; + std::vector> runtimes_; +}; + +} // namespace gateway diff --git a/components/gateway_bridge/src/gateway_bridge.cpp b/components/gateway_bridge/src/gateway_bridge.cpp new file mode 100644 index 0000000..76d193b --- /dev/null +++ b/components/gateway_bridge/src/gateway_bridge.cpp @@ -0,0 +1,1450 @@ +#include "gateway_bridge.hpp" + +#if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) +#include "bacnet_bridge.hpp" +#include "gateway_bacnet.hpp" +#endif +#include "bridge.hpp" +#include "bridge_model.hpp" +#include "bridge_provisioning.hpp" +#include "dali_comm.hpp" +#include "dali_domain.hpp" +#include "gateway_cloud.hpp" +#include "gateway_provisioning.hpp" +#include "modbus_bridge.hpp" + +#include "cJSON.h" +#include "esp_log.h" +#include "freertos/semphr.h" +#include "lwip/inet.h" +#include "lwip/sockets.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace gateway { + +namespace { + +constexpr const char* kTag = "gateway_bridge"; +constexpr int kDefaultModbusPort = 1502; +constexpr size_t kModbusMaxPduBytes = 252; + +class LockGuard { + public: + explicit LockGuard(SemaphoreHandle_t lock) : lock_(lock) { + if (lock_ != nullptr) { + xSemaphoreTakeRecursive(lock_, portMAX_DELAY); + } + } + + ~LockGuard() { + if (lock_ != nullptr) { + xSemaphoreGiveRecursive(lock_); + } + } + + private: + SemaphoreHandle_t lock_; +}; + +std::string PrintJson(cJSON* node) { + if (node == nullptr) { + return "{}"; + } + char* rendered = cJSON_PrintUnformatted(node); + if (rendered == nullptr) { + return "{}"; + } + std::string out(rendered); + cJSON_free(rendered); + return out; +} + +GatewayBridgeHttpResponse JsonOk(cJSON* node) { + const std::string body = PrintJson(node); + cJSON_Delete(node); + return GatewayBridgeHttpResponse{ESP_OK, body}; +} + +GatewayBridgeHttpResponse ErrorResponse(esp_err_t err, const char* message) { + cJSON* root = cJSON_CreateObject(); + if (root != nullptr) { + cJSON_AddBoolToObject(root, "ok", false); + cJSON_AddStringToObject(root, "error", message == nullptr ? "error" : message); + cJSON_AddNumberToObject(root, "espErr", static_cast(err)); + } + const std::string body = PrintJson(root); + cJSON_Delete(root); + return GatewayBridgeHttpResponse{err, body}; +} + +const char* JsonString(const cJSON* parent, const char* name) { + const cJSON* item = cJSON_GetObjectItemCaseSensitive(parent, name); + return cJSON_IsString(item) && item->valuestring != nullptr ? item->valuestring : nullptr; +} + +std::optional JsonInt(const cJSON* parent, const char* name) { + const cJSON* item = cJSON_GetObjectItemCaseSensitive(parent, name); + if (!cJSON_IsNumber(item)) { + return std::nullopt; + } + return item->valueint; +} + +std::optional JsonGatewayId(const cJSON* root) { + if (root == nullptr) { + return std::nullopt; + } + const auto gateway = JsonInt(root, "gw").value_or(JsonInt(root, "gatewayId").value_or(-1)); + if (gateway < 0 || gateway > 255) { + return std::nullopt; + } + return static_cast(gateway); +} + +std::string QueryValue(std::string_view query, std::string_view key) { + if (query.empty() || key.empty()) { + return {}; + } + size_t start = 0; + while (start < query.size()) { + const size_t end = query.find('&', start); + const size_t segment_end = end == std::string_view::npos ? query.size() : end; + const std::string_view segment = query.substr(start, segment_end - start); + const size_t equals = segment.find('='); + if (equals != std::string_view::npos && segment.substr(0, equals) == key) { + return std::string(segment.substr(equals + 1)); + } + if (end == std::string_view::npos) { + break; + } + start = end + 1; + } + return {}; +} + +std::optional ParseInt(std::string_view raw) { + if (raw.empty()) { + return std::nullopt; + } + std::string text(raw); + char* end = nullptr; + const long parsed = std::strtol(text.c_str(), &end, 10); + if (end == text.c_str() || *end != '\0') { + return std::nullopt; + } + return static_cast(parsed); +} + +std::optional QueryInt(std::string_view query, std::string_view primary, + std::string_view fallback = {}) { + auto value = ParseInt(QueryValue(query, primary)); + if (value.has_value() || fallback.empty()) { + return value; + } + return ParseInt(QueryValue(query, fallback)); +} + +std::optional JsonIntAny(const cJSON* parent, const char* primary, const char* fallback) { + auto value = JsonInt(parent, primary); + if (value.has_value() || fallback == nullptr) { + return value; + } + return JsonInt(parent, fallback); +} + +bool ValidDaliAddress(int address) { + return address >= 0 && address <= 127; +} + +cJSON* IntArrayToCjson(const std::vector& values) { + cJSON* array = cJSON_CreateArray(); + if (array == nullptr) { + return nullptr; + } + for (const int value : values) { + cJSON_AddItemToArray(array, cJSON_CreateNumber(value)); + } + return array; +} + +cJSON* NumberArrayToCjson(const std::vector& values) { + cJSON* array = cJSON_CreateArray(); + if (array == nullptr) { + return nullptr; + } + for (const double value : values) { + cJSON_AddItemToArray(array, cJSON_CreateNumber(value)); + } + return array; +} + +cJSON* SnapshotToCjson(const DaliDomainSnapshot& snapshot) { + cJSON* root = cJSON_CreateObject(); + if (root == nullptr) { + return nullptr; + } + cJSON_AddNumberToObject(root, "gatewayId", snapshot.gateway_id); + cJSON_AddNumberToObject(root, "address", snapshot.address); + cJSON_AddStringToObject(root, "kind", snapshot.kind.c_str()); + for (const auto& entry : snapshot.bools) { + cJSON_AddBoolToObject(root, entry.first.c_str(), entry.second); + } + for (const auto& entry : snapshot.ints) { + cJSON_AddNumberToObject(root, entry.first.c_str(), entry.second); + } + for (const auto& entry : snapshot.numbers) { + cJSON_AddNumberToObject(root, entry.first.c_str(), entry.second); + } + for (const auto& entry : snapshot.int_arrays) { + cJSON_AddItemToObject(root, entry.first.c_str(), IntArrayToCjson(entry.second)); + } + for (const auto& entry : snapshot.number_arrays) { + cJSON_AddItemToObject(root, entry.first.c_str(), NumberArrayToCjson(entry.second)); + } + return root; +} + +GatewayBridgeHttpResponse SnapshotResponse(const std::optional& snapshot, + const char* missing_message) { + if (!snapshot.has_value()) { + return ErrorResponse(ESP_ERR_NOT_FOUND, missing_message); + } + return JsonOk(SnapshotToCjson(snapshot.value())); +} + +GatewayBridgeHttpResponse StoredSnapshotResponse( + const std::optional& snapshot, uint8_t gateway_id, int address, + const char* kind) { + if (snapshot.has_value()) { + return JsonOk(SnapshotToCjson(snapshot.value())); + } + cJSON* root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddBoolToObject(root, "stored", true); + cJSON_AddBoolToObject(root, "reportAvailable", false); + cJSON_AddNumberToObject(root, "gatewayId", gateway_id); + cJSON_AddNumberToObject(root, "address", address); + cJSON_AddStringToObject(root, "kind", kind == nullptr ? "dt8_snapshot" : kind); + return JsonOk(root); +} + +DaliDt8SceneColorMode JsonColorMode(const cJSON* root) { + const cJSON* item = cJSON_GetObjectItemCaseSensitive(root, "colorMode"); + if (item == nullptr) { + item = cJSON_GetObjectItemCaseSensitive(root, "color_mode"); + } + if (cJSON_IsNumber(item)) { + if (item->valueint == 1) { + return DaliDt8SceneColorMode::kColorTemperature; + } + if (item->valueint == 2) { + return DaliDt8SceneColorMode::kRgb; + } + return DaliDt8SceneColorMode::kDisabled; + } + if (!cJSON_IsString(item) || item->valuestring == nullptr) { + return DaliDt8SceneColorMode::kDisabled; + } + const std::string_view mode(item->valuestring); + if (mode == "colorTemperature" || mode == "color_temperature" || mode == "ct") { + return DaliDt8SceneColorMode::kColorTemperature; + } + if (mode == "rgb") { + return DaliDt8SceneColorMode::kRgb; + } + return DaliDt8SceneColorMode::kDisabled; +} + +GatewayBridgeHttpResponse StoreDt8SceneSnapshot(DaliDomainService& domain, uint8_t gateway_id, + std::string_view body) { + cJSON* root = cJSON_ParseWithLength(body.data(), body.size()); + if (root == nullptr) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid DT8 scene snapshot JSON"); + } + const auto address = JsonIntAny(root, "addr", "address"); + const auto scene = JsonInt(root, "scene"); + const auto brightness = JsonInt(root, "brightness"); + const auto color_mode = JsonColorMode(root); + const int color_temperature = JsonIntAny(root, "colorTemperature", "color_temperature").value_or(0); + const int red = JsonIntAny(root, "red", "r").value_or(0); + const int green = JsonIntAny(root, "green", "g").value_or(0); + const int blue = JsonIntAny(root, "blue", "b").value_or(0); + cJSON_Delete(root); + + if (!address.has_value() || !scene.has_value() || !brightness.has_value() || + !ValidDaliAddress(address.value()) || scene.value() < 0 || scene.value() > 15) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "addr, scene, and brightness are required"); + } + if (!domain.storeDt8SceneSnapshot(gateway_id, address.value(), scene.value(), + brightness.value(), color_mode, color_temperature, red, + green, blue)) { + return ErrorResponse(ESP_FAIL, "failed to store DT8 scene snapshot"); + } + return StoredSnapshotResponse(domain.dt8SceneColorReport(gateway_id, address.value(), + scene.value()), + gateway_id, address.value(), "dt8_scene"); +} + +GatewayBridgeHttpResponse StoreDt8LevelSnapshot(DaliDomainService& domain, uint8_t gateway_id, + std::string_view body, bool power_on) { + cJSON* root = cJSON_ParseWithLength(body.data(), body.size()); + if (root == nullptr) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid DT8 level snapshot JSON"); + } + const auto address = JsonIntAny(root, "addr", "address"); + const auto level = JsonInt(root, "level"); + cJSON_Delete(root); + + if (!address.has_value() || !level.has_value() || !ValidDaliAddress(address.value())) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "addr and level are required"); + } + + const bool stored = power_on + ? domain.storeDt8PowerOnLevelSnapshot(gateway_id, address.value(), + level.value()) + : domain.storeDt8SystemFailureLevelSnapshot(gateway_id, address.value(), + level.value()); + if (!stored) { + return ErrorResponse(ESP_FAIL, power_on ? "failed to store DT8 power-on snapshot" + : "failed to store DT8 system-failure snapshot"); + } + return StoredSnapshotResponse(power_on ? domain.dt8PowerOnLevelColorReport(gateway_id, + address.value()) + : domain.dt8SystemFailureLevelColorReport( + gateway_id, address.value()), + gateway_id, address.value(), + power_on ? "dt8_power_on" : "dt8_system_failure"); +} + +DaliValue FromCjson(const cJSON* item) { + if (item == nullptr || cJSON_IsNull(item)) { + return DaliValue(); + } + if (cJSON_IsBool(item)) { + return DaliValue(cJSON_IsTrue(item)); + } + if (cJSON_IsNumber(item)) { + const double value = item->valuedouble; + if (value == static_cast(item->valueint)) { + return DaliValue(item->valueint); + } + return DaliValue(value); + } + if (cJSON_IsString(item) && item->valuestring != nullptr) { + return DaliValue(std::string(item->valuestring)); + } + if (cJSON_IsArray(item)) { + DaliValue::Array out; + for (const cJSON* child = item->child; child != nullptr; child = child->next) { + out.push_back(FromCjson(child)); + } + return DaliValue(std::move(out)); + } + if (cJSON_IsObject(item)) { + DaliValue::Object out; + for (const cJSON* child = item->child; child != nullptr; child = child->next) { + if (child->string != nullptr) { + out[child->string] = FromCjson(child); + } + } + return DaliValue(std::move(out)); + } + return DaliValue(); +} + +cJSON* ToCjson(const DaliValue& value) { + if (value.isNull()) { + return cJSON_CreateNull(); + } + if (value.isBool()) { + return cJSON_CreateBool(value.asBool().value_or(false)); + } + if (value.isInt()) { + return cJSON_CreateNumber(value.asInt().value_or(0)); + } + if (value.isDouble()) { + return cJSON_CreateNumber(value.asDouble().value_or(0.0)); + } + if (value.isString()) { + return cJSON_CreateString(value.asString().value_or("").c_str()); + } + if (const auto* array = value.asArray()) { + cJSON* out = cJSON_CreateArray(); + for (const auto& item : *array) { + cJSON_AddItemToArray(out, ToCjson(item)); + } + return out; + } + if (const auto* object = value.asObject()) { + cJSON* out = cJSON_CreateObject(); + for (const auto& entry : *object) { + cJSON_AddItemToObject(out, entry.first.c_str(), ToCjson(entry.second)); + } + return out; + } + return cJSON_CreateNull(); +} + +std::string BridgeRuntimeConfigToJson(const BridgeRuntimeConfig& config) { + cJSON* root = ToCjson(DaliValue(config.toJson())); + const std::string body = PrintJson(root); + cJSON_Delete(root); + return body; +} + +std::optional BridgeRuntimeConfigFromJson(std::string_view json) { + cJSON* root = cJSON_ParseWithLength(json.data(), json.size()); + if (root == nullptr) { + return std::nullopt; + } + const DaliValue value = FromCjson(root); + cJSON_Delete(root); + const auto* object = value.asObject(); + if (object == nullptr) { + return std::nullopt; + } + return BridgeRuntimeConfig::fromJson(*object); +} + +GatewayCloudConfig GatewayCloudConfigFromJson(cJSON* root) { + GatewayCloudConfig config; + if (const char* value = JsonString(root, "brokerURI")) { + config.brokerURI = value; + } + if (const char* value = JsonString(root, "deviceID")) { + config.deviceID = value; + } + if (const char* value = JsonString(root, "username")) { + config.username = value; + } + if (const char* value = JsonString(root, "password")) { + config.password = value; + } + if (const char* value = JsonString(root, "topicPrefix")) { + config.topicPrefix = value; + } + if (const auto qos = JsonInt(root, "qos")) { + config.qos = qos.value(); + } + return config; +} + +cJSON* GatewayCloudConfigToCjson(const GatewayCloudConfig& config) { + cJSON* root = cJSON_CreateObject(); + if (root == nullptr) { + return nullptr; + } + cJSON_AddStringToObject(root, "brokerURI", config.brokerURI.c_str()); + cJSON_AddStringToObject(root, "deviceID", config.deviceID.c_str()); + cJSON_AddStringToObject(root, "username", config.username.c_str()); + cJSON_AddStringToObject(root, "password", config.password.c_str()); + cJSON_AddStringToObject(root, "topicPrefix", config.topicPrefix.c_str()); + cJSON_AddNumberToObject(root, "qos", config.qos); + return root; +} + +DaliBridgeRequest BridgeRequestFromJson(cJSON* root) { + DaliBridgeRequest request; + if (const char* seq = JsonString(root, "seq")) { + request.sequence = seq; + } + if (const char* model = JsonString(root, "model")) { + request.modelID = model; + } + if (const char* op = JsonString(root, "op")) { + request.operation = bridgeOperationFromString(op); + } + if (const auto addr = JsonInt(root, "addr")) { + request.rawAddress = addr.value(); + } + if (const auto cmd = JsonInt(root, "cmd")) { + request.rawCommand = cmd.value(); + } + if (const auto short_address = JsonInt(root, "shortAddress")) { + request.shortAddress = short_address.value(); + } + if (const cJSON* value = cJSON_GetObjectItemCaseSensitive(root, "value")) { + request.value = FromCjson(value); + } + if (const cJSON* meta = cJSON_GetObjectItemCaseSensitive(root, "meta")) { + const auto meta_value = FromCjson(meta); + if (const auto* object = meta_value.asObject()) { + request.metadata = *object; + } + } + return request; +} + +cJSON* BridgeResultToCjson(const DaliBridgeResult& result) { + return ToCjson(DaliValue(result.toJson())); +} + +uint16_t ReadBe16(const uint8_t* data) { + return static_cast((static_cast(data[0]) << 8) | data[1]); +} + +void WriteBe16(uint8_t* data, uint16_t value) { + data[0] = static_cast((value >> 8) & 0xFF); + data[1] = static_cast(value & 0xFF); +} + +bool RecvAll(int sock, uint8_t* buffer, size_t len) { + size_t received = 0; + while (received < len) { + const int ret = recv(sock, buffer + received, len - received, 0); + if (ret <= 0) { + return false; + } + received += static_cast(ret); + } + return true; +} + +bool SendAll(int sock, const uint8_t* buffer, size_t len) { + size_t sent = 0; + while (sent < len) { + const int ret = send(sock, buffer + sent, len - sent, 0); + if (ret <= 0) { + return false; + } + sent += static_cast(ret); + } + return true; +} + +bool SendModbusFrame(int sock, const uint8_t* mbap, const std::vector& pdu) { + std::vector frame(7 + pdu.size()); + std::memcpy(frame.data(), mbap, 7); + WriteBe16(&frame[4], static_cast(pdu.size() + 1)); + std::memcpy(frame.data() + 7, pdu.data(), pdu.size()); + return SendAll(sock, frame.data(), frame.size()); +} + +bool SendModbusException(int sock, const uint8_t* mbap, uint8_t function_code, + uint8_t exception_code) { + const std::vector pdu{static_cast(function_code | 0x80), exception_code}; + return SendModbusFrame(sock, mbap, pdu); +} + +int HoldingRegisterFromWireAddress(uint16_t zero_based_address) { + return 40001 + static_cast(zero_based_address); +} + +} // namespace + +struct GatewayBridgeService::ChannelRuntime { + explicit ChannelRuntime(DaliDomainService& domain, DaliChannelInfo channel, + GatewayBridgeServiceConfig service_config) + : domain(domain), channel(std::move(channel)), service_config(service_config), + lock(xSemaphoreCreateRecursiveMutex()) {} + + ~ChannelRuntime() { + if (cloud != nullptr) { + cloud->stop(); + } + if (lock != nullptr) { + vSemaphoreDelete(lock); + lock = nullptr; + } + } + + DaliDomainService& domain; + DaliChannelInfo channel; + GatewayBridgeServiceConfig service_config; + SemaphoreHandle_t lock{nullptr}; + std::unique_ptr comm; + std::unique_ptr engine; + std::unique_ptr modbus; +#if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) + std::unique_ptr bacnet; +#endif + std::unique_ptr cloud; + BridgeRuntimeConfig bridge_config; + std::optional cloud_config; + bool bridge_config_loaded{false}; + bool cloud_config_loaded{false}; + bool cloud_started{false}; + bool modbus_started{false}; + bool bacnet_started{false}; + TaskHandle_t modbus_task_handle{nullptr}; + + static void ModbusTaskEntry(void* arg) { + static_cast(arg)->modbusTaskLoop(); + } + + std::string bridgeNamespace() const { + return "dali_bridge_" + std::to_string(channel.gateway_id); + } + + std::string cloudNamespace() const { + return "dali_cloud_" + std::to_string(channel.gateway_id); + } + + esp_err_t start() { + comm = std::make_unique( + [this](const uint8_t* data, size_t len) { + return domain.writeBridgeFrame(channel.gateway_id, data, len); + }, + nullptr, + [this](const uint8_t* data, size_t len) { + return domain.transactBridgeFrame(channel.gateway_id, data, len); + }, + [](uint32_t ms) { vTaskDelay(pdMS_TO_TICKS(ms)); }); + + BridgeProvisioningStore bridge_store(bridgeNamespace()); + bridge_config_loaded = bridge_store.load(&bridge_config) == ESP_OK; + applyBridgeConfigLocked(); + + GatewayProvisioningStore cloud_store(cloudNamespace()); + GatewayCloudConfig loaded_cloud; + if (cloud_store.load(&loaded_cloud) == ESP_OK) { + cloud_config = loaded_cloud; + cloud_config_loaded = true; + } + applyCloudModelsLocked(); + + if (service_config.cloud_enabled && service_config.cloud_startup_enabled) { + startCloudLocked(); + } + return ESP_OK; + } + + void applyBridgeConfigLocked() { + engine = std::make_unique(*comm); + for (const auto& model : bridge_config.models) { + engine->upsertModel(model); + } + + modbus = std::make_unique(*engine); + if (bridge_config.modbus.has_value()) { + modbus->setConfig(bridge_config.modbus.value()); + } + +#if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) + if (service_config.bacnet_enabled) { + bacnet = std::make_unique(*engine); + if (bridge_config.bacnet.has_value()) { + bacnet->setConfig(bridge_config.bacnet.value()); + } + } else { + bacnet.reset(); + } +#endif + + applyCloudModelsLocked(); + bacnet_started = false; + } + + void applyCloudModelsLocked() { + if (cloud_started && cloud != nullptr) { + cloud->stop(); + cloud_started = false; + } + cloud = std::make_unique(*comm); + for (const auto& model : bridge_config.models) { + cloud->bridge().upsertModel(model); + } + } + + esp_err_t saveBridgeConfig(std::string_view json) { + auto parsed = BridgeRuntimeConfigFromJson(json); + if (!parsed.has_value()) { + return ESP_ERR_INVALID_ARG; + } + BridgeProvisioningStore store(bridgeNamespace()); + const esp_err_t err = store.save(parsed.value()); + if (err != ESP_OK) { + return err; + } + LockGuard guard(lock); + bridge_config = parsed.value(); + bridge_config_loaded = true; + applyBridgeConfigLocked(); + return ESP_OK; + } + + esp_err_t clearBridgeConfig() { + BridgeProvisioningStore store(bridgeNamespace()); + const esp_err_t err = store.clear(); + if (err != ESP_OK) { + return err; + } + LockGuard guard(lock); + bridge_config = BridgeRuntimeConfig{}; + bridge_config_loaded = false; + applyBridgeConfigLocked(); + return ESP_OK; + } + + esp_err_t saveCloudConfig(std::string_view json) { + cJSON* root = cJSON_ParseWithLength(json.data(), json.size()); + if (root == nullptr) { + return ESP_ERR_INVALID_ARG; + } + const GatewayCloudConfig parsed = GatewayCloudConfigFromJson(root); + cJSON_Delete(root); + GatewayProvisioningStore store(cloudNamespace()); + const esp_err_t err = store.save(parsed); + if (err != ESP_OK) { + return err; + } + LockGuard guard(lock); + cloud_config = parsed; + cloud_config_loaded = true; + if (cloud_started) { + startCloudLocked(); + } + return ESP_OK; + } + + esp_err_t clearCloudConfig() { + GatewayProvisioningStore store(cloudNamespace()); + const esp_err_t err = store.clear(); + if (err != ESP_OK) { + return err; + } + LockGuard guard(lock); + if (cloud != nullptr) { + cloud->stop(); + } + cloud_config.reset(); + cloud_config_loaded = false; + cloud_started = false; + applyCloudModelsLocked(); + return ESP_OK; + } + + esp_err_t startCloud() { + LockGuard guard(lock); + return startCloudLocked(); + } + + esp_err_t startCloudLocked() { + if (!service_config.cloud_enabled) { + return ESP_ERR_NOT_SUPPORTED; + } + if (!cloud_config.has_value()) { + return ESP_ERR_NOT_FOUND; + } + if (cloud == nullptr) { + applyCloudModelsLocked(); + } + if (cloud->start(cloud_config.value())) { + cloud_started = true; + return ESP_OK; + } + cloud_started = false; + return ESP_FAIL; + } + + esp_err_t stopCloud() { + LockGuard guard(lock); + if (cloud != nullptr) { + cloud->stop(); + } + cloud_started = false; + return ESP_OK; + } + + std::string modelName(const std::string& model_id) const { + const auto model = std::find_if(bridge_config.models.begin(), bridge_config.models.end(), + [&model_id](const auto& item) { return item.id == model_id; }); + return model == bridge_config.models.end() ? model_id : model->displayName(); + } + +#if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) + GatewayBacnetServerConfig bacnetServerConfigLocked() const { + GatewayBacnetServerConfig config; + config.device_name = channel.name.empty() ? "DALI Gateway " + std::to_string(channel.gateway_id) + : channel.name; + config.task_stack_size = service_config.bacnet_task_stack_size; + config.task_priority = service_config.bacnet_task_priority; + if (bacnet != nullptr) { + const auto& bridge_config = bacnet->config(); + config.device_instance = bridge_config.deviceInstance; + config.local_address = bridge_config.localAddress; + config.udp_port = bridge_config.udpPort; + } + return config; + } + + std::vector bacnetObjectBindingsLocked() const { + std::vector bindings; + if (bacnet == nullptr) { + return bindings; + } + for (const auto& binding : bacnet->describeObjects()) { + if (binding.objectInstance < 0) { + continue; + } + bindings.push_back(GatewayBacnetObjectBinding{channel.gateway_id, + binding.modelID, + modelName(binding.modelID), + binding.objectType, + static_cast(binding.objectInstance), + binding.property.empty() ? "presentValue" + : binding.property}); + } + return bindings; + } + + bool handleBacnetWrite(BridgeObjectType object_type, uint32_t object_instance, + const std::string& property, const DaliValue& value) { + LockGuard guard(lock); + if (bacnet == nullptr) { + return false; + } + const DaliBridgeResult result = bacnet->handlePropertyWrite( + object_type, static_cast(object_instance), property, value); + if (!result.ok) { + ESP_LOGW(kTag, "gateway=%u BACnet write rejected: %s", channel.gateway_id, + result.error.c_str()); + } + return result.ok; + } + + esp_err_t startBacnet() { + LockGuard guard(lock); + if (!service_config.bacnet_enabled) { + return ESP_ERR_NOT_SUPPORTED; + } + if (bacnet == nullptr) { + return ESP_ERR_INVALID_STATE; + } + const auto bindings = bacnetObjectBindingsLocked(); + if (bindings.empty()) { + return ESP_ERR_NOT_FOUND; + } + const auto server_config = bacnetServerConfigLocked(); + const esp_err_t err = GatewayBacnetServer::instance().registerChannel( + channel.gateway_id, server_config, bindings, + [this](BridgeObjectType object_type, uint32_t object_instance, + const std::string& property, const DaliValue& value) { + return handleBacnetWrite(object_type, object_instance, property, value); + }); + bacnet_started = err == ESP_OK; + return err; + } +#else + esp_err_t startBacnet() { + return ESP_ERR_NOT_SUPPORTED; + } +#endif + + GatewayBridgeHttpResponse execute(std::string_view json) { + cJSON* root = cJSON_ParseWithLength(json.data(), json.size()); + if (root == nullptr) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid execute JSON"); + } + const DaliBridgeRequest request = BridgeRequestFromJson(root); + cJSON_Delete(root); + + LockGuard guard(lock); + if (engine == nullptr) { + return ErrorResponse(ESP_ERR_INVALID_STATE, "bridge engine is not ready"); + } + const DaliBridgeResult result = engine->execute(request); + return JsonOk(BridgeResultToCjson(result)); + } + + cJSON* statusCjson() const { + cJSON* root = cJSON_CreateObject(); + if (root == nullptr) { + return nullptr; + } + cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); + cJSON_AddNumberToObject(root, "channel", channel.channel_index); + cJSON_AddStringToObject(root, "name", channel.name.c_str()); + cJSON_AddBoolToObject(root, "bridgeConfigLoaded", bridge_config_loaded); + cJSON_AddNumberToObject(root, "modelCount", static_cast(bridge_config.models.size())); + + cJSON* modbus_json = cJSON_CreateObject(); + if (modbus_json != nullptr) { + cJSON_AddBoolToObject(modbus_json, "enabled", service_config.modbus_enabled); + cJSON_AddBoolToObject(modbus_json, "started", modbus_started); + if (bridge_config.modbus.has_value()) { + cJSON_AddStringToObject(modbus_json, "transport", bridge_config.modbus->transport.c_str()); + cJSON_AddNumberToObject(modbus_json, "port", bridge_config.modbus->port); + cJSON_AddNumberToObject(modbus_json, "unitID", bridge_config.modbus->unitID); + } + cJSON_AddItemToObject(root, "modbus", modbus_json); + } + + cJSON* bacnet_json = cJSON_CreateObject(); + if (bacnet_json != nullptr) { +#if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) + const auto server_status = GatewayBacnetServer::instance().status(); +#endif + cJSON_AddBoolToObject(bacnet_json, "enabled", service_config.bacnet_enabled); + cJSON_AddBoolToObject(bacnet_json, "startupEnabled", service_config.bacnet_startup_enabled); + cJSON_AddBoolToObject(bacnet_json, "started", bacnet_started); +#if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) + cJSON_AddBoolToObject(bacnet_json, "serverStarted", server_status.started); + cJSON_AddNumberToObject(bacnet_json, "serverObjectCount", + static_cast(server_status.object_count)); +#else + cJSON_AddBoolToObject(bacnet_json, "serverStarted", false); + cJSON_AddNumberToObject(bacnet_json, "serverObjectCount", 0); +#endif + if (bridge_config.bacnet.has_value()) { + cJSON_AddNumberToObject(bacnet_json, "deviceInstance", bridge_config.bacnet->deviceInstance); + cJSON_AddStringToObject(bacnet_json, "localAddress", bridge_config.bacnet->localAddress.c_str()); + cJSON_AddNumberToObject(bacnet_json, "udpPort", bridge_config.bacnet->udpPort); + } + cJSON_AddItemToObject(root, "bacnet", bacnet_json); + } + + cJSON* cloud_json = cJSON_CreateObject(); + if (cloud_json != nullptr) { + cJSON_AddBoolToObject(cloud_json, "enabled", service_config.cloud_enabled); + cJSON_AddBoolToObject(cloud_json, "configured", cloud_config_loaded); + cJSON_AddBoolToObject(cloud_json, "started", cloud_started); + cJSON_AddBoolToObject(cloud_json, "connected", cloud != nullptr && cloud->isConnected()); + if (cloud_config.has_value()) { + cJSON_AddStringToObject(cloud_json, "deviceID", cloud_config->deviceID.c_str()); + cJSON_AddStringToObject(cloud_json, "topicPrefix", cloud_config->topicPrefix.c_str()); + } + cJSON_AddItemToObject(root, "cloud", cloud_json); + } + + return root; + } + + GatewayBridgeHttpResponse configJson() const { + return GatewayBridgeHttpResponse{ESP_OK, BridgeRuntimeConfigToJson(bridge_config)}; + } + + GatewayBridgeHttpResponse modbusBindingsJson() const { + cJSON* root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); + cJSON* bindings = cJSON_CreateArray(); + if (bindings != nullptr && modbus != nullptr) { + for (const auto& binding : modbus->describeHoldingRegisters()) { + cJSON* item = cJSON_CreateObject(); + if (item == nullptr) { + continue; + } + cJSON_AddStringToObject(item, "model", binding.modelID.c_str()); + cJSON_AddNumberToObject(item, "registerAddress", binding.registerAddress); + cJSON_AddItemToArray(bindings, item); + } + } + cJSON_AddItemToObject(root, "bindings", bindings); + return JsonOk(root); + } + + GatewayBridgeHttpResponse bacnetBindingsJson() const { + cJSON* root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); + cJSON* bindings = cJSON_CreateArray(); +#if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) + if (bindings != nullptr && bacnet != nullptr) { + for (const auto& binding : bacnet->describeObjects()) { + cJSON* item = cJSON_CreateObject(); + if (item == nullptr) { + continue; + } + cJSON_AddStringToObject(item, "model", binding.modelID.c_str()); + cJSON_AddStringToObject(item, "objectType", bridgeObjectTypeToString(binding.objectType)); + cJSON_AddNumberToObject(item, "objectInstance", binding.objectInstance); + cJSON_AddStringToObject(item, "property", binding.property.c_str()); + cJSON_AddItemToArray(bindings, item); + } + } +#endif + cJSON_AddItemToObject(root, "bindings", bindings); + cJSON* server_json = cJSON_CreateObject(); + if (server_json != nullptr) { +#if defined(CONFIG_GATEWAY_BACNET_BRIDGE_SUPPORTED) + const auto server_status = GatewayBacnetServer::instance().status(); + cJSON_AddBoolToObject(server_json, "started", server_status.started); + cJSON_AddNumberToObject(server_json, "deviceInstance", server_status.device_instance); + cJSON_AddNumberToObject(server_json, "udpPort", server_status.udp_port); + cJSON_AddNumberToObject(server_json, "channelCount", + static_cast(server_status.channel_count)); + cJSON_AddNumberToObject(server_json, "objectCount", + static_cast(server_status.object_count)); +#else + cJSON_AddBoolToObject(server_json, "started", false); + cJSON_AddNumberToObject(server_json, "deviceInstance", 0); + cJSON_AddNumberToObject(server_json, "udpPort", 0); + cJSON_AddNumberToObject(server_json, "channelCount", 0); + cJSON_AddNumberToObject(server_json, "objectCount", 0); +#endif + cJSON_AddItemToObject(root, "server", server_json); + } + return JsonOk(root); + } + + GatewayBridgeHttpResponse cloudJson() const { + cJSON* root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "gatewayId", channel.gateway_id); + cJSON_AddBoolToObject(root, "configured", cloud_config_loaded); + cJSON_AddBoolToObject(root, "started", cloud_started); + cJSON_AddBoolToObject(root, "connected", cloud != nullptr && cloud->isConnected()); + if (cloud_config.has_value()) { + cJSON_AddItemToObject(root, "config", GatewayCloudConfigToCjson(cloud_config.value())); + } + return JsonOk(root); + } + + esp_err_t startModbus(std::set* used_ports = nullptr) { + LockGuard guard(lock); + if (!service_config.modbus_enabled) { + return ESP_ERR_NOT_SUPPORTED; + } + if (modbus_started || modbus_task_handle != nullptr) { + return ESP_OK; + } + if (!bridge_config.modbus.has_value()) { + return ESP_ERR_NOT_FOUND; + } + const uint16_t port = bridge_config.modbus->port == 0 ? kDefaultModbusPort + : bridge_config.modbus->port; + if (used_ports != nullptr) { + if (used_ports->find(port) != used_ports->end()) { + ESP_LOGW(kTag, "gateway=%u skips duplicate Modbus TCP port %u", channel.gateway_id, port); + return ESP_ERR_INVALID_STATE; + } + used_ports->insert(port); + } + const BaseType_t created = xTaskCreate(&ChannelRuntime::ModbusTaskEntry, "gw_modbus_tcp", + service_config.modbus_task_stack_size, this, + service_config.modbus_task_priority, + &modbus_task_handle); + if (created != pdPASS) { + modbus_task_handle = nullptr; + return ESP_ERR_NO_MEM; + } + modbus_started = true; + return ESP_OK; + } + + void modbusTaskLoop() { + const uint16_t port = bridge_config.modbus.has_value() && bridge_config.modbus->port != 0 + ? bridge_config.modbus->port + : kDefaultModbusPort; + const int listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_IP); + if (listen_sock < 0) { + ESP_LOGE(kTag, "gateway=%u failed to create Modbus socket", channel.gateway_id); + modbus_started = false; + modbus_task_handle = nullptr; + vTaskDelete(nullptr); + return; + } + + int reuse = 1; + setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + sockaddr_in address = {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_ANY); + address.sin_port = htons(port); + + if (bind(listen_sock, reinterpret_cast(&address), sizeof(address)) != 0 || + listen(listen_sock, 2) != 0) { + ESP_LOGE(kTag, "gateway=%u failed to bind Modbus TCP port %u", channel.gateway_id, port); + close(listen_sock); + modbus_started = false; + modbus_task_handle = nullptr; + vTaskDelete(nullptr); + return; + } + + ESP_LOGI(kTag, "gateway=%u Modbus TCP listening on port %u", channel.gateway_id, port); + while (true) { + sockaddr_in client_address = {}; + socklen_t client_len = sizeof(client_address); + const int client_sock = accept(listen_sock, reinterpret_cast(&client_address), + &client_len); + if (client_sock < 0) { + continue; + } + handleModbusClient(client_sock); + close(client_sock); + } + } + + void handleModbusClient(int client_sock) { + uint8_t header[7] = {}; + while (RecvAll(client_sock, header, sizeof(header))) { + const uint16_t protocol_id = ReadBe16(&header[2]); + const uint16_t length = ReadBe16(&header[4]); + if (protocol_id != 0 || length < 2 || length > kModbusMaxPduBytes) { + break; + } + + std::vector pdu(length - 1); + if (!RecvAll(client_sock, pdu.data(), pdu.size()) || pdu.empty()) { + break; + } + + if (bridge_config.modbus.has_value() && bridge_config.modbus->unitID != 0 && + header[6] != bridge_config.modbus->unitID) { + SendModbusException(client_sock, header, pdu[0], 0x0B); + continue; + } + + if (pdu[0] == 0x06 && pdu.size() == 5) { + const uint16_t wire_register = ReadBe16(&pdu[1]); + const uint16_t value = ReadBe16(&pdu[3]); + const int holding_register = HoldingRegisterFromWireAddress(wire_register); + const auto result = handleHoldingRegisterWrite(holding_register, value); + if (!result.ok) { + SendModbusException(client_sock, header, pdu[0], 0x04); + continue; + } + SendModbusFrame(client_sock, header, pdu); + continue; + } + + if (pdu[0] == 0x10 && pdu.size() >= 6) { + const uint16_t start_register = ReadBe16(&pdu[1]); + const uint16_t quantity = ReadBe16(&pdu[3]); + const uint8_t byte_count = pdu[5]; + if (pdu.size() != static_cast(6 + byte_count) || byte_count != quantity * 2) { + SendModbusException(client_sock, header, pdu[0], 0x03); + continue; + } + bool ok = true; + for (uint16_t index = 0; index < quantity; ++index) { + const size_t offset = 6 + (index * 2); + const uint16_t value = ReadBe16(&pdu[offset]); + const int holding_register = HoldingRegisterFromWireAddress(start_register + index); + const auto result = handleHoldingRegisterWrite(holding_register, value); + if (!result.ok) { + ok = false; + break; + } + } + if (!ok) { + SendModbusException(client_sock, header, pdu[0], 0x04); + continue; + } + std::vector response(5); + response[0] = pdu[0]; + WriteBe16(&response[1], start_register); + WriteBe16(&response[3], quantity); + SendModbusFrame(client_sock, header, response); + continue; + } + + SendModbusException(client_sock, header, pdu[0], 0x01); + } + } + + DaliBridgeResult handleHoldingRegisterWrite(int holding_register, int value) { + LockGuard guard(lock); + if (modbus == nullptr) { + DaliBridgeResult result; + result.sequence = "modbus-" + std::to_string(holding_register); + result.error = "modbus bridge not ready"; + return result; + } + return modbus->handleHoldingRegisterWrite(holding_register, value); + } +}; + +GatewayBridgeService::GatewayBridgeService(DaliDomainService& dali_domain, + GatewayBridgeServiceConfig config) + : dali_domain_(dali_domain), config_(config) {} + +GatewayBridgeService::~GatewayBridgeService() = default; + +esp_err_t GatewayBridgeService::start() { + if (!config_.bridge_enabled) { + ESP_LOGI(kTag, "bridge service disabled"); + return ESP_OK; + } + if (!runtimes_.empty()) { + return ESP_OK; + } + + const auto channels = dali_domain_.channelInfo(); + runtimes_.reserve(channels.size()); + for (const auto& channel : channels) { + auto runtime = std::make_unique(dali_domain_, channel, config_); + const esp_err_t err = runtime->start(); + if (err != ESP_OK) { + ESP_LOGE(kTag, "failed to start bridge runtime gateway=%u: %s", channel.gateway_id, + esp_err_to_name(err)); + return err; + } + runtimes_.push_back(std::move(runtime)); + } + + if (config_.modbus_enabled && config_.modbus_startup_enabled) { + std::set used_ports; + for (const auto& runtime : runtimes_) { + const esp_err_t err = runtime->startModbus(&used_ports); + if (err != ESP_OK && err != ESP_ERR_NOT_FOUND && err != ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(kTag, "gateway=%u Modbus startup skipped: %s", runtime->channel.gateway_id, + esp_err_to_name(err)); + } + } + } + + if (config_.bacnet_enabled && config_.bacnet_startup_enabled) { + for (const auto& runtime : runtimes_) { + const esp_err_t err = runtime->startBacnet(); + if (err != ESP_OK && err != ESP_ERR_NOT_FOUND && err != ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(kTag, "gateway=%u BACnet startup skipped: %s", runtime->channel.gateway_id, + esp_err_to_name(err)); + } + } + } + + ESP_LOGI(kTag, "bridge service started channels=%u", static_cast(runtimes_.size())); + return ESP_OK; +} + +GatewayBridgeService::ChannelRuntime* GatewayBridgeService::findRuntime(uint8_t gateway_id) { + for (const auto& runtime : runtimes_) { + if (runtime->channel.gateway_id == gateway_id) { + return runtime.get(); + } + } + return nullptr; +} + +const GatewayBridgeService::ChannelRuntime* GatewayBridgeService::findRuntime( + uint8_t gateway_id) const { + for (const auto& runtime : runtimes_) { + if (runtime->channel.gateway_id == gateway_id) { + return runtime.get(); + } + } + return nullptr; +} + +GatewayBridgeHttpResponse GatewayBridgeService::handleGet( + const std::string& action_arg, int gateway_id_arg, const std::string& query_arg) const { + if (!config_.bridge_enabled) { + return ErrorResponse(ESP_ERR_NOT_SUPPORTED, "bridge service is disabled"); + } + std::string_view action(action_arg); + std::string_view query(query_arg); + std::optional gateway_id; + if (gateway_id_arg >= 0 && gateway_id_arg <= 255) { + gateway_id = static_cast(gateway_id_arg); + } + if (action.empty()) { + action = "status"; + } + + if (action == "status" && !gateway_id.has_value()) { + cJSON* root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "enabled", true); + cJSON_AddNumberToObject(root, "count", static_cast(runtimes_.size())); + cJSON* channels = cJSON_CreateArray(); + if (channels != nullptr) { + for (const auto& runtime : runtimes_) { + cJSON_AddItemToArray(channels, runtime->statusCjson()); + } + cJSON_AddItemToObject(root, "channels", channels); + } + return JsonOk(root); + } + + if (!gateway_id.has_value()) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "gateway id is required"); + } + const auto* runtime = findRuntime(gateway_id.value()); + if (runtime == nullptr) { + return ErrorResponse(ESP_ERR_NOT_FOUND, "unknown gateway id"); + } + + if (action == "status") { + return JsonOk(runtime->statusCjson()); + } + if (action == "config") { + return runtime->configJson(); + } + if (action == "modbus") { + return runtime->modbusBindingsJson(); + } + if (action == "bacnet") { + return runtime->bacnetBindingsJson(); + } + if (action == "cloud") { + return runtime->cloudJson(); + } + if (action == "device") { + const auto address = QueryInt(query, "addr", "address"); + if (!address.has_value() || !ValidDaliAddress(address.value())) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "valid addr is required"); + } + return SnapshotResponse(dali_domain_.discoverDeviceTypes(gateway_id.value(), address.value()), + "device did not respond to type discovery"); + } + if (action == "dt4" || action == "dt5" || action == "dt6") { + const auto address = QueryInt(query, "addr", "address"); + if (!address.has_value() || !ValidDaliAddress(address.value())) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "valid addr is required"); + } + if (action == "dt4") { + return SnapshotResponse(dali_domain_.dt4Snapshot(gateway_id.value(), address.value()), + "DT4 snapshot is unavailable"); + } + if (action == "dt5") { + return SnapshotResponse(dali_domain_.dt5Snapshot(gateway_id.value(), address.value()), + "DT5 snapshot is unavailable"); + } + return SnapshotResponse(dali_domain_.dt6Snapshot(gateway_id.value(), address.value()), + "DT6 snapshot is unavailable"); + } + if (action == "dt8_scene") { + const auto address = QueryInt(query, "addr", "address"); + const auto scene = QueryInt(query, "scene"); + if (!address.has_value() || !scene.has_value() || !ValidDaliAddress(address.value()) || + scene.value() < 0 || scene.value() > 15) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "valid addr and scene are required"); + } + return SnapshotResponse(dali_domain_.dt8SceneColorReport(gateway_id.value(), address.value(), + scene.value()), + "DT8 scene color report is unavailable"); + } + if (action == "dt8_power_on") { + const auto address = QueryInt(query, "addr", "address"); + if (!address.has_value() || !ValidDaliAddress(address.value())) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "valid addr is required"); + } + return SnapshotResponse(dali_domain_.dt8PowerOnLevelColorReport(gateway_id.value(), + address.value()), + "DT8 power-on color report is unavailable"); + } + if (action == "dt8_system_failure") { + const auto address = QueryInt(query, "addr", "address"); + if (!address.has_value() || !ValidDaliAddress(address.value())) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "valid addr is required"); + } + return SnapshotResponse(dali_domain_.dt8SystemFailureLevelColorReport(gateway_id.value(), + address.value()), + "DT8 system-failure color report is unavailable"); + } + + return ErrorResponse(ESP_ERR_INVALID_ARG, "unknown bridge GET action"); +} + +GatewayBridgeHttpResponse GatewayBridgeService::handlePost( + const std::string& action_arg, int gateway_id_arg, const std::string& body_arg) { + if (!config_.bridge_enabled) { + return ErrorResponse(ESP_ERR_NOT_SUPPORTED, "bridge service is disabled"); + } + + std::string_view action(action_arg); + std::string_view body(body_arg); + std::optional gateway_id; + if (gateway_id_arg >= 0 && gateway_id_arg <= 255) { + gateway_id = static_cast(gateway_id_arg); + } + + cJSON* root = body.empty() ? nullptr : cJSON_ParseWithLength(body.data(), body.size()); + if (!body.empty() && root == nullptr) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "invalid JSON body"); + } + if (action.empty() && root != nullptr) { + if (const char* body_action = JsonString(root, "action")) { + action = body_action; + } + } + if (!gateway_id.has_value()) { + gateway_id = JsonGatewayId(root); + } + cJSON_Delete(root); + + if (action.empty()) { + action = "execute"; + } + if (!gateway_id.has_value()) { + return ErrorResponse(ESP_ERR_INVALID_ARG, "gateway id is required"); + } + + auto* runtime = findRuntime(gateway_id.value()); + if (runtime == nullptr) { + return ErrorResponse(ESP_ERR_NOT_FOUND, "unknown gateway id"); + } + + if (action == "dt8_scene_snapshot" || action == "store_dt8_scene_snapshot") { + return StoreDt8SceneSnapshot(dali_domain_, gateway_id.value(), body); + } + if (action == "dt8_power_on_snapshot" || action == "store_dt8_power_on_snapshot") { + return StoreDt8LevelSnapshot(dali_domain_, gateway_id.value(), body, true); + } + if (action == "dt8_system_failure_snapshot" || + action == "store_dt8_system_failure_snapshot") { + return StoreDt8LevelSnapshot(dali_domain_, gateway_id.value(), body, false); + } + + if (action == "execute") { + return runtime->execute(body); + } + if (action == "config" || action == "save_config") { + const esp_err_t err = runtime->saveBridgeConfig(body); + if (err != ESP_OK) { + return ErrorResponse(err, "failed to save bridge config"); + } + return handleGet("config", gateway_id.value()); + } + if (action == "clear_config") { + const esp_err_t err = runtime->clearBridgeConfig(); + if (err != ESP_OK) { + return ErrorResponse(err, "failed to clear bridge config"); + } + return handleGet("config", gateway_id.value()); + } + if (action == "cloud" || action == "save_cloud") { + const esp_err_t err = runtime->saveCloudConfig(body); + if (err != ESP_OK) { + return ErrorResponse(err, "failed to save cloud config"); + } + return handleGet("cloud", gateway_id.value()); + } + if (action == "cloud_start") { + const esp_err_t err = runtime->startCloud(); + if (err != ESP_OK) { + return ErrorResponse(err, "failed to start cloud bridge"); + } + return handleGet("cloud", gateway_id.value()); + } + if (action == "cloud_stop") { + const esp_err_t err = runtime->stopCloud(); + if (err != ESP_OK) { + return ErrorResponse(err, "failed to stop cloud bridge"); + } + return handleGet("cloud", gateway_id.value()); + } + if (action == "cloud_clear") { + const esp_err_t err = runtime->clearCloudConfig(); + if (err != ESP_OK) { + return ErrorResponse(err, "failed to clear cloud config"); + } + return handleGet("cloud", gateway_id.value()); + } + if (action == "modbus_start") { + const esp_err_t err = runtime->startModbus(); + if (err != ESP_OK) { + return ErrorResponse(err, "failed to start Modbus TCP bridge"); + } + return handleGet("modbus", gateway_id.value()); + } + if (action == "bacnet_start") { + const esp_err_t err = runtime->startBacnet(); + if (err != ESP_OK) { + return ErrorResponse(err, "failed to start BACnet/IP bridge"); + } + return handleGet("bacnet", gateway_id.value()); + } + + return ErrorResponse(ESP_ERR_INVALID_ARG, "unknown bridge POST action"); +} + +} // namespace gateway diff --git a/components/gateway_network/CMakeLists.txt b/components/gateway_network/CMakeLists.txt index fa632ae..009e150 100644 --- a/components/gateway_network/CMakeLists.txt +++ b/components/gateway_network/CMakeLists.txt @@ -1,7 +1,7 @@ idf_component_register( SRCS "src/gateway_network.cpp" INCLUDE_DIRS "include" - REQUIRES dali_domain esp_event esp_http_server esp_netif esp_wifi freertos gateway_controller gateway_runtime log lwip espressif__cjson + REQUIRES dali_domain esp_event esp_http_server esp_netif esp_wifi freertos gateway_bridge gateway_controller gateway_runtime log lwip espressif__cjson ) set_property(TARGET ${COMPONENT_LIB} PROPERTY CXX_STANDARD 17) \ No newline at end of file diff --git a/components/gateway_network/include/gateway_network.hpp b/components/gateway_network/include/gateway_network.hpp index 58d395a..f31ec5a 100644 --- a/components/gateway_network/include/gateway_network.hpp +++ b/components/gateway_network/include/gateway_network.hpp @@ -21,6 +21,7 @@ namespace gateway { class GatewayController; class DaliDomainService; +class GatewayBridgeService; struct DaliRawFrame; struct GatewayNetworkServiceConfig { @@ -48,7 +49,9 @@ struct GatewayNetworkServiceConfig { class GatewayNetworkService { public: GatewayNetworkService(GatewayController& controller, GatewayRuntime& runtime, - DaliDomainService& dali_domain, GatewayNetworkServiceConfig config = {}); + DaliDomainService& dali_domain, + GatewayNetworkServiceConfig config = {}, + GatewayBridgeService* bridge_service = nullptr); esp_err_t start(); @@ -58,6 +61,8 @@ class GatewayNetworkService { static esp_err_t HandleInfoGet(httpd_req_t* req); static esp_err_t HandleCommandGet(httpd_req_t* req); static esp_err_t HandleCommandPost(httpd_req_t* req); + static esp_err_t HandleBridgeGet(httpd_req_t* req); + static esp_err_t HandleBridgePost(httpd_req_t* req); static esp_err_t HandleLedOnGet(httpd_req_t* req); static esp_err_t HandleLedOffGet(httpd_req_t* req); static esp_err_t HandleJqJsGet(httpd_req_t* req); @@ -91,12 +96,14 @@ class GatewayNetworkService { std::string deviceInfoJson() const; std::string deviceInfoDoubleEncodedJson() const; std::string gatewaySnapshotJson(); + esp_err_t sendBridgeResponse(httpd_req_t* req, bool post); void setStatusLed(bool on); GatewayController& controller_; GatewayRuntime& runtime_; DaliDomainService& dali_domain_; GatewayNetworkServiceConfig config_; + GatewayBridgeService* bridge_service_{nullptr}; bool started_{false}; httpd_handle_t http_server_{nullptr}; esp_netif_t* wifi_sta_netif_{nullptr}; diff --git a/components/gateway_network/src/gateway_network.cpp b/components/gateway_network/src/gateway_network.cpp index 6457640..52d2169 100644 --- a/components/gateway_network/src/gateway_network.cpp +++ b/components/gateway_network/src/gateway_network.cpp @@ -1,6 +1,7 @@ #include "gateway_network.hpp" #include "dali_domain.hpp" +#include "gateway_bridge.hpp" #include "gateway_controller.hpp" #include "gateway_runtime.hpp" @@ -18,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -182,14 +184,79 @@ esp_err_t RegisterUri(httpd_handle_t server, const char* uri, httpd_method_t met return httpd_register_uri_handler(server, &route); } +std::string QueryValue(httpd_req_t* req, const char* key) { + if (req == nullptr || key == nullptr) { + return {}; + } + const size_t len = httpd_req_get_url_query_len(req) + 1; + if (len <= 1) { + return {}; + } + std::string query(len, '\0'); + if (httpd_req_get_url_query_str(req, query.data(), query.size()) != ESP_OK) { + return {}; + } + char value[64] = {0}; + if (httpd_query_key_value(query.c_str(), key, value, sizeof(value)) != ESP_OK) { + return {}; + } + return std::string(value); +} + +std::string QueryString(httpd_req_t* req) { + if (req == nullptr) { + return {}; + } + const size_t len = httpd_req_get_url_query_len(req) + 1; + if (len <= 1) { + return {}; + } + std::string query(len, '\0'); + if (httpd_req_get_url_query_str(req, query.data(), query.size()) != ESP_OK) { + return {}; + } + if (!query.empty() && query.back() == '\0') { + query.pop_back(); + } + return query; +} + +std::optional QueryGatewayId(httpd_req_t* req) { + const auto raw = QueryValue(req, "gw"); + if (raw.empty()) { + return std::nullopt; + } + char* end = nullptr; + const long parsed = std::strtol(raw.c_str(), &end, 10); + if (end == raw.c_str() || *end != '\0' || parsed < 0 || parsed > 255) { + return std::nullopt; + } + return static_cast(parsed); +} + +esp_err_t SendJsonResponse(httpd_req_t* req, const GatewayBridgeHttpResponse& response) { + if (response.err == ESP_ERR_INVALID_ARG) { + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, response.body.c_str()); + } + if (response.err == ESP_ERR_NOT_FOUND) { + return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, response.body.c_str()); + } + if (response.err != ESP_OK) { + return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, response.body.c_str()); + } + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, response.body.data(), response.body.size()); +} + } // namespace GatewayNetworkService::GatewayNetworkService(GatewayController& controller, GatewayRuntime& runtime, DaliDomainService& dali_domain, - GatewayNetworkServiceConfig config) + GatewayNetworkServiceConfig config, + GatewayBridgeService* bridge_service) : controller_(controller), runtime_(runtime), dali_domain_(dali_domain), config_(config), - udp_lock_(xSemaphoreCreateMutex()) {} + bridge_service_(bridge_service), udp_lock_(xSemaphoreCreateMutex()) {} esp_err_t GatewayNetworkService::start() { if (started_) { @@ -635,6 +702,8 @@ esp_err_t GatewayNetworkService::startHttpServer() { {"/info", HTTP_GET, &GatewayNetworkService::HandleInfoGet}, {"/dali/cmd", HTTP_GET, &GatewayNetworkService::HandleCommandGet}, {"/dali/cmd", HTTP_POST, &GatewayNetworkService::HandleCommandPost}, + {"/bridge", HTTP_GET, &GatewayNetworkService::HandleBridgeGet}, + {"/bridge", HTTP_POST, &GatewayNetworkService::HandleBridgePost}, {"/led/1", HTTP_GET, &GatewayNetworkService::HandleLedOnGet}, {"/led/0", HTTP_GET, &GatewayNetworkService::HandleLedOffGet}, {"/jq.js", HTTP_GET, &GatewayNetworkService::HandleJqJsGet}, @@ -1227,6 +1296,42 @@ esp_err_t GatewayNetworkService::HandleCommandPost(httpd_req_t* req) { return httpd_resp_sendstr(req, "ok"); } +esp_err_t GatewayNetworkService::HandleBridgeGet(httpd_req_t* req) { + auto* service = static_cast(req->user_ctx); + if (service == nullptr) { + return ESP_FAIL; + } + return service->sendBridgeResponse(req, false); +} + +esp_err_t GatewayNetworkService::HandleBridgePost(httpd_req_t* req) { + auto* service = static_cast(req->user_ctx); + if (service == nullptr) { + return ESP_FAIL; + } + return service->sendBridgeResponse(req, true); +} + +esp_err_t GatewayNetworkService::sendBridgeResponse(httpd_req_t* req, bool post) { + if (bridge_service_ == nullptr) { + return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Bridge service is not enabled"); + } + + const std::string action = QueryValue(req, "action"); + const auto gateway_id = QueryGatewayId(req); + const int selected_gateway_id = gateway_id.has_value() ? gateway_id.value() : -1; + if (!post) { + return SendJsonResponse(req, bridge_service_->handleGet(action, selected_gateway_id, + QueryString(req))); + } + + std::string body; + if (ReadRequestBody(req, body) != ESP_OK) { + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad Request"); + } + return SendJsonResponse(req, bridge_service_->handlePost(action, selected_gateway_id, body)); +} + esp_err_t GatewayNetworkService::HandleLedOnGet(httpd_req_t* req) { auto* service = static_cast(req->user_ctx); if (service == nullptr) {