Files
gateway/components/gateway_bridge/src/gateway_bridge.cpp
T

3969 lines
150 KiB
C++

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