diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a11449..8c6bc6d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,31 +1,60 @@ -idf_component_register( - SRCS - "src/dali_comm.cpp" - "src/base.cpp" - "src/addr.cpp" - "src/bridge.cpp" - "src/bridge_model.cpp" - "src/bridge_provisioning.cpp" - "src/decode.cpp" - "src/decode_device_type.cpp" - "src/decode_dt1.cpp" - "src/decode_dt4.cpp" - "src/decode_dt5.cpp" - "src/decode_dt6.cpp" - "src/decode_dt8.cpp" - "src/device.cpp" - "src/dt1.cpp" - "src/dt4.cpp" - "src/dt5.cpp" - "src/dt6.cpp" - "src/dt8.cpp" - "src/sequence.cpp" - "src/sequence_store.cpp" - "src/color.cpp" - "src/gateway_cloud.cpp" - "src/gateway_provisioning.cpp" - INCLUDE_DIRS "include" - REQUIRES mqtt cjson nvs_flash esp_driver_uart +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR AND "$ENV{IDF_PATH}" STREQUAL "") + cmake_minimum_required(VERSION 3.16) + project(dali_cpp_gateway LANGUAGES CXX) +endif() + +set(DALI_CPP_GATEWAY_SOURCES + "src/dali_protocol.cpp" + "src/dali_gateway_cache.cpp" + "src/dali_gateway_cache_mutation.cpp" + "src/dali_gateway_reconciliation.cpp" + "src/dali_application_controller.cpp" ) -set_property(TARGET ${COMPONENT_LIB} PROPERTY CXX_STANDARD 17) +if(COMMAND idf_component_register) + idf_component_register( + SRCS + "src/dali_comm.cpp" + ${DALI_CPP_GATEWAY_SOURCES} + "src/base.cpp" + "src/addr.cpp" + "src/bridge.cpp" + "src/bridge_model.cpp" + "src/bridge_provisioning.cpp" + "src/decode.cpp" + "src/decode_device_type.cpp" + "src/decode_dt1.cpp" + "src/decode_dt4.cpp" + "src/decode_dt5.cpp" + "src/decode_dt6.cpp" + "src/decode_dt8.cpp" + "src/device.cpp" + "src/dt1.cpp" + "src/dt4.cpp" + "src/dt5.cpp" + "src/dt6.cpp" + "src/dt8.cpp" + "src/sequence.cpp" + "src/sequence_store.cpp" + "src/color.cpp" + "src/gateway_cloud.cpp" + "src/gateway_provisioning.cpp" + INCLUDE_DIRS "include" + REQUIRES mqtt cjson nvs_flash esp_driver_uart + ) + + set_property(TARGET ${COMPONENT_LIB} PROPERTY CXX_STANDARD 17) +else() + # Standalone target for IoT adapters that only need the platform-neutral + # DALI gateway application behavior. + add_library(dali_cpp_gateway STATIC ${DALI_CPP_GATEWAY_SOURCES}) + target_include_directories(dali_cpp_gateway PUBLIC "${CMAKE_CURRENT_LIST_DIR}/include") + target_compile_features(dali_cpp_gateway PUBLIC cxx_std_17) + + include(CTest) + if(BUILD_TESTING) + add_executable(dali_cpp_gateway_test "tests/dali_gateway_test.cpp") + target_link_libraries(dali_cpp_gateway_test PRIVATE dali_cpp_gateway) + add_test(NAME dali_cpp_gateway_test COMMAND dali_cpp_gateway_test) + endif() +endif() diff --git a/README.md b/README.md index 40b5ca8..201fdaf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,105 @@ -# DALI ESP-IDF Component +# DALI C++ Library -A lightweight C++ implementation of the DALI stack used in `lib/dali/*.dart` for gateway type 1 (USB UART) traffic. The component mirrors Dart method names so it can be used as an embedded replacement target. +A C++ implementation of the DALI stack used in `lib/dali/*.dart`. The +portable DALI application layer mirrors Dart method names where practical, so +it can become an embedded replacement target without starting that migration +yet. + +## Portable Gateway Application Layer + +`include/dali_gateway.hpp` is the hardware- and platform-independent gateway +API. It uses only the C++17 standard library and deliberately owns neither a +transceiver, a clock, a task, nor a platform storage API: + +- `DaliGatewayCache` interprets direct, group, and broadcast forward frames; + owns cached DALI state, persistence encoding, dirty tracking, and + reconciliation work. +- `DaliApplicationController` implements the Part 103 logical application + controller: standard control-device queries, `IDENTIFY DEVICE`, and control + device short-address commissioning (`INITIALISE`, `RANDOMISE`, search, + `COMPARE`, `PROGRAM SHORT ADDRESS`, `VERIFY`, and `QUERY SHORT ADDRESS`). +- The transport/adapter supplies the `doubleSendConfirmed` argument for + commands that must be sent twice, sends returned backward frames, persists + address state through registered key/value callbacks, and maps + `identifyRequested` to its own physical indication. + +This lets another IoT protocol use the DALI behavior without pulling in +ESP-IDF. The current ESP-IDF gateway is one such adapter: its NVS wrapper and +FreeRTOS/PHY timing stay under `gateway/components/`. Existing cloud and +provisioning helpers remain compatibility integrations for the ESP-IDF +component; they are not used by the portable gateway API. + +For a non-ESP project, add this directory with CMake and link the focused +`dali_cpp_gateway` target: + +```cmake +add_subdirectory(path/to/dali_cpp) +target_link_libraries(my_iot_adapter PRIVATE dali_cpp_gateway) +``` + +### Bind a transceiver and an IoT protocol + +The portable cache reports semantic status changes, so an adapter does not +need to decode DALI opcodes before updating KNX, Modbus, BACnet, MQTT, or +another IoT protocol: + +```cpp +DaliGatewayCache cache({ + true, // cache enabled + true, // reconcile changes observed from another DALI master + false, // only reconcile the affected state category + DaliGatewayCachePriorityMode::outsideBusFirst, +}); + +cache.setStatusUpdateCallback([](const DaliGatewayStatusUpdate& update) { + // update.target is direct, group, or broadcast. + // update.status contains the interpreted level/scene state. + // update.affectedShortAddresses is ready for protocols that expose + // per-device status. + my_iot_adapter.publishDaliStatus(update); +}); + +cache.setPersistenceCallbacks({ + [](uint8_t channel, uint8_t address) { + return my_storage.load(channel, address); + }, + [](uint8_t channel, uint8_t address, + const std::optional& payload) { + return my_storage.storeOrErase(channel, address, payload); + }, + []() { return my_storage.commit(); }, +}); + +cache.preloadChannel(0); + +my_transceiver.onForwardFrame([&cache](uint8_t address, uint8_t data) { + cache.observeForwardFrame(0, address, data, + DaliGatewayFrameOrigin::outsideBus); +}); + +// After a locally requested transmission succeeds, mirror it once. The cache +// performs all direct/group/broadcast and DTR-dependent interpretation. +if (my_transceiver.send(address, data)) { + cache.mirrorForwardFrame(0, address, data); +} + +// Call from the adapter's low-priority worker or shutdown path. +cache.flush(); +``` + +The callbacks contain no ESP-IDF, RTOS, NVS, network, or transceiver types. +An adapter chooses its own tasking, storage, bus timing, and IoT protocol. + +The portable implementation is deliberately split by responsibility: + +- `dali_protocol.cpp` decodes DALI target addressing. +- `dali_gateway_cache.cpp` owns cache state and adapter-facing persistence + snapshots. +- `dali_gateway_cache_mutation.cpp` applies direct, group, broadcast, scene, + DTR, and settings feedback. +- `dali_gateway_reconciliation.cpp` derives external-bus reconciliation work. +- `dali_application_controller.cpp` implements Part 103 control-device + queries, commissioning, and identity semantics. ## Quick Start @@ -220,7 +319,7 @@ Source the helper script from your shell so the exported ESP-IDF variables stay . ./scripts/export_esp_idf.sh ``` -The helper script sources `~/esp/v5.5.2/esp-idf/export.sh`. +The helper script sources the installed ESP-IDF v5.5.4 `export.sh`. ### Build the Example diff --git a/include/dali.hpp b/include/dali.hpp index 466be18..6368ed3 100644 --- a/include/dali.hpp +++ b/include/dali.hpp @@ -15,6 +15,7 @@ #include "dt6.hpp" #include "dt8.hpp" #include "addr.hpp" +#include "dali_gateway.hpp" #include "gateway_cloud.hpp" #include "gateway_provisioning.hpp" #include "color.hpp" @@ -27,7 +28,8 @@ #include #include -// Convenience umbrella header for the ESP-IDF DALI component. +// Convenience umbrella header for the complete ESP-IDF DALI component. +// Platform-neutral gateway adapters can include dali_gateway.hpp directly. class Dali { public: diff --git a/include/dali_gateway.hpp b/include/dali_gateway.hpp new file mode 100644 index 0000000..c322499 --- /dev/null +++ b/include/dali_gateway.hpp @@ -0,0 +1,272 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Hardware- and platform-independent DALI gateway application primitives. +// +// A transport owns frame timing, collision handling, persistence, and the +// actual transmission of backward frames. This header only interprets DALI +// forward frames and maintains the high-level state that an IoT adapter can +// expose to its own protocol. + +enum class DaliGatewayCachePriorityMode : uint8_t { + outsideBusFirst = 0, + localGatewayFirst = 1, +}; + +enum class DaliGatewayFrameOrigin : uint8_t { + localGateway = 0, + outsideBus = 1, +}; + +enum class DaliGatewayTargetKind : uint8_t { + shortAddress = 0, + group = 1, + broadcast = 2, +}; + +enum class DaliGatewayPresence : uint8_t { + unknown = 0, + online = 1, + offline = 2, +}; + +struct DaliGatewayTarget { + DaliGatewayTargetKind kind{DaliGatewayTargetKind::shortAddress}; + uint8_t value{0}; +}; + +struct DaliGatewayChannelFlags { + bool needUpdateGroup{false}; + bool needUpdateScene{false}; + bool needUpdateSettings{false}; + + bool any() const { + return needUpdateGroup || needUpdateScene || needUpdateSettings; + } +}; + +struct DaliGatewaySettingsSnapshot { + std::optional powerOnLevel; + std::optional systemFailureLevel; + std::optional minLevel; + std::optional maxLevel; + std::optional fadeTime; + std::optional fadeRate; + + bool anyKnown() const { + return powerOnLevel.has_value() || systemFailureLevel.has_value() || minLevel.has_value() || + maxLevel.has_value() || fadeTime.has_value() || fadeRate.has_value(); + } +}; + +struct DaliGatewayRuntimeStatus { + std::optional actualLevel; + std::optional sceneID; + bool useMinLevel{false}; + bool stale{false}; + uint32_t revision{0}; + + bool anyKnown() const { + return actualLevel.has_value() || sceneID.has_value() || useMinLevel; + } +}; + +struct DaliGatewayAddressState { + bool groupMaskKnown{false}; + uint16_t groupMask{0}; + std::array, 16> sceneLevels{}; + DaliGatewaySettingsSnapshot settings; + DaliGatewayRuntimeStatus status; +}; + +struct DaliGatewayCacheConfig { + bool enabled{true}; + bool reconciliationEnabled{true}; + bool fullStateMirrorEnabled{false}; + DaliGatewayCachePriorityMode priorityMode{DaliGatewayCachePriorityMode::outsideBusFirst}; +}; + +struct DaliGatewayStatusUpdate { + uint8_t channel{0}; + DaliGatewayTarget target; + DaliGatewayRuntimeStatus status; + std::vector affectedShortAddresses; +}; + +struct DaliGatewayCachePersistenceCallbacks { + // The adapter maps a channel/address pair to its own storage. The portable + // cache owns the encoded payload and its versioning. + std::function(uint8_t channel, uint8_t shortAddress)> load; + std::function& payload)> + store; + std::function commit; +}; + +class DaliGatewayCache { + public: + using StatusUpdateCallback = std::function; + + explicit DaliGatewayCache(DaliGatewayCacheConfig config = {}); + + void configure(DaliGatewayCacheConfig config); + bool enabled() const; + bool setEnabled(bool enabled); + bool reconciliationEnabled() const; + bool fullStateMirrorEnabled() const; + DaliGatewayCachePriorityMode priorityMode() const; + void setPriorityMode(DaliGatewayCachePriorityMode mode); + void setStatusUpdateCallback(StatusUpdateCallback callback); + void setPersistenceCallbacks(DaliGatewayCachePersistenceCallbacks callbacks); + + static std::optional decodeTarget(uint8_t rawAddress); + + DaliGatewayAddressState addressState(uint8_t channel, uint8_t shortAddress) const; + DaliGatewayPresence addressPresence(uint8_t channel, uint8_t shortAddress) const; + DaliGatewayRuntimeStatus groupStatus(uint8_t channel, uint8_t group) const; + DaliGatewayRuntimeStatus broadcastStatus(uint8_t channel) const; + DaliGatewayChannelFlags channelFlags(uint8_t channel) const; + DaliGatewayChannelFlags pendingChannelFlags(uint8_t channel) const; + + void markAddressPresence(uint8_t channel, uint8_t shortAddress, DaliGatewayPresence presence); + bool setGroupMask(uint8_t channel, uint8_t shortAddress, std::optional groupMask); + bool setSceneLevel(uint8_t channel, uint8_t shortAddress, uint8_t scene, + std::optional level); + bool setSettings(uint8_t channel, uint8_t shortAddress, + std::optional settings); + bool setActualLevel(uint8_t channel, uint8_t shortAddress, std::optional level); + + // Call after a successful locally initiated command, or for an observed + // command that should update the state mirror. The result is true only if + // a DALI state value changed. + bool mirrorForwardFrame(uint8_t channel, uint8_t rawAddress, uint8_t command); + bool observeForwardFrame(uint8_t channel, uint8_t rawAddress, uint8_t command, + DaliGatewayFrameOrigin origin); + + std::vector reconciliationAddresses( + uint8_t channel, std::optional target = std::nullopt) const; + bool clearChannelFlagsIfMatched(uint8_t channel, const DaliGatewayChannelFlags& expected); + void markGroupUpdateNeeded(uint8_t channel, bool needed = true); + void markSceneUpdateNeeded(uint8_t channel, bool needed = true); + void markSettingsUpdateNeeded(uint8_t channel, bool needed = true); + + // The cache owns persistence encoding and dirty tracking. The adapter only + // binds platform storage callbacks and decides when preload/flush run. + std::array addressStates(uint8_t channel) const; + void restoreAddressStates(uint8_t channel, + const std::array& states); + bool preloadChannel(uint8_t channel); + bool flush(); + + private: + struct DtrState { + std::optional dtr0; + std::optional dtr1; + std::optional dtr2; + }; + + using AddressStates = std::array; + using PresenceStates = std::array; + using GroupStatuses = std::array; + + AddressStates& ensureStates(uint8_t channel); + PresenceStates& ensurePresence(uint8_t channel); + GroupStatuses& ensureGroupStatuses(uint8_t channel); + DaliGatewayRuntimeStatus& ensureBroadcastStatus(uint8_t channel); + uint32_t nextRevision(); + bool mirrorForwardFrameLocked(uint8_t channel, uint8_t rawAddress, uint8_t command, + std::optional* statusUpdate); + std::optional statusUpdate( + uint8_t channel, const DaliGatewayTarget& target) const; + void markDirty(uint8_t channel); + void clearTarget(uint8_t channel, const DaliGatewayTarget& target, uint32_t revision); + void applyRuntimeStatus(uint8_t channel, const DaliGatewayTarget& target, + const DaliGatewayRuntimeStatus& status); + static void applyRuntimeStatusToAddress(DaliGatewayAddressState& address, + const DaliGatewayRuntimeStatus& status); + void applyGroupMutation(uint8_t channel, const DaliGatewayTarget& target, uint8_t group, + bool add); + void applySceneMutation(uint8_t channel, const DaliGatewayTarget& target, uint8_t scene, + std::optional level); + void applySettingsMutation(uint8_t channel, const DaliGatewayTarget& target, uint8_t command, + uint8_t value); + void refreshAggregateStatus(uint8_t channel, DaliGatewayAddressState& address); + static std::optional encodeAddressState( + const DaliGatewayAddressState& state); + static std::optional decodeAddressState(std::string_view payload); + + mutable std::recursive_mutex mutex_; + DaliGatewayCacheConfig config_; + StatusUpdateCallback statusUpdateCallback_; + DaliGatewayCachePersistenceCallbacks persistence_; + std::map states_; + std::map presence_; + std::map groupStatuses_; + std::map broadcastStatuses_; + std::map dtrStates_; + std::map flags_; + std::map dirtyGenerations_; + uint32_t revision_{0}; +}; + +struct DaliApplicationControllerConfig { + // nullopt models an unaddressed control device. A commissioning adapter can + // persist this value and restore it at boot. + std::optional shortAddress; + uint32_t randomAddress{0x00D10301U}; + uint8_t versionNumber{2}; + uint8_t extendedVersionNumber{1}; + bool applicationControllerEnabled{true}; + bool applicationControllerAlwaysActive{false}; +}; + +struct DaliApplicationControllerResult { + std::optional backwardFrame; + bool identifyRequested{false}; + bool shortAddressChanged{false}; + std::optional shortAddress; +}; + +// IEC 62386-103 logical control device with application-controller capability. +// The caller supplies whether a command that is specified as "send twice" has +// been confirmed by its transport/timing layer; this keeps timing out of the +// library while preserving the required command semantics. +class DaliApplicationController { + public: + explicit DaliApplicationController(DaliApplicationControllerConfig config = {}); + + const DaliApplicationControllerConfig& config() const; + std::optional shortAddress() const; + void setShortAddress(std::optional shortAddress); + + DaliApplicationControllerResult handleForwardFrame(const std::array& frame, + bool doubleSendConfirmed = false); + + private: + bool isAddressed(const std::array& frame) const; + bool isSelectedForCommissioning() const; + uint32_t searchAddress() const; + std::optional queryResponse(uint8_t opcode) const; + void reset(); + + DaliApplicationControllerConfig config_; + bool powerCycleNotificationEnabled_{true}; + bool powerCycleSeen_{true}; + bool resetState_{false}; + bool initialising_{false}; + bool withdrawn_{false}; + uint8_t operatingMode_{0}; + uint8_t dtr0_{0}; + uint8_t dtr1_{0}; + uint8_t dtr2_{0}; + uint32_t searchAddress_{0}; +}; diff --git a/scripts/export_esp_idf.sh b/scripts/export_esp_idf.sh index e29f578..bbf3065 100644 --- a/scripts/export_esp_idf.sh +++ b/scripts/export_esp_idf.sh @@ -1,8 +1,8 @@ -ESP_IDF_EXPORT_PATH="${HOME}/esp/v5.5.2/esp-idf/export.sh" +ESP_IDF_EXPORT_PATH="${HOME}/.espressif/v5.5.4/esp-idf/export.sh" if [ ! -f "$ESP_IDF_EXPORT_PATH" ]; then echo "ESP-IDF export script not found at $ESP_IDF_EXPORT_PATH" >&2 return 1 2>/dev/null || exit 1 fi -. "$ESP_IDF_EXPORT_PATH" \ No newline at end of file +. "$ESP_IDF_EXPORT_PATH" diff --git a/src/dali_application_controller.cpp b/src/dali_application_controller.cpp new file mode 100644 index 0000000..3e76233 --- /dev/null +++ b/src/dali_application_controller.cpp @@ -0,0 +1,266 @@ +#include "dali_gateway.hpp" + +namespace { + +bool IsDoubleSendCommand(const std::array& frame) { + if (frame[0] == 0xC1) { + return frame[1] == 0x01 || frame[1] == 0x02; // INITIALISE, RANDOMISE + } + if ((frame[0] & 1U) == 0 || frame[1] != 0xFE) return false; + switch (frame[2]) { + case 0x00: // IDENTIFY DEVICE + case 0x01: // RESET POWER CYCLE SEEN + case 0x10: // RESET + case 0x16: // ENABLE APPLICATION CONTROLLER + case 0x17: // DISABLE APPLICATION CONTROLLER + case 0x18: // SET OPERATING MODE + case 0x1F: // ENABLE POWER CYCLE NOTIFICATION + case 0x20: // DISABLE POWER CYCLE NOTIFICATION + case 0x21: // SAVE PERSISTENT VARIABLES + return true; + default: + return false; + } +} + +} // namespace + +DaliApplicationController::DaliApplicationController(DaliApplicationControllerConfig config) + : config_(config), searchAddress_(config.randomAddress & 0xFFFFFFU) { + if (config_.shortAddress.has_value() && *config_.shortAddress > 63) { + config_.shortAddress.reset(); + } + config_.randomAddress &= 0xFFFFFFU; +} + +const DaliApplicationControllerConfig& DaliApplicationController::config() const { + return config_; +} + +std::optional DaliApplicationController::shortAddress() const { + return config_.shortAddress; +} + +void DaliApplicationController::setShortAddress(std::optional shortAddress) { + config_.shortAddress = shortAddress.has_value() && *shortAddress <= 63 ? shortAddress + : std::nullopt; +} + +bool DaliApplicationController::isAddressed(const std::array& frame) const { + return (frame[0] & 1U) != 0 && + (frame[0] == 0xFF || (config_.shortAddress.has_value() && + frame[0] == static_cast( + (*config_.shortAddress << 1) | 1U))); +} + +bool DaliApplicationController::isSelectedForCommissioning() const { + return initialising_ && !withdrawn_ && config_.randomAddress == searchAddress(); +} + +uint32_t DaliApplicationController::searchAddress() const { + return searchAddress_ & 0xFFFFFFU; +} + +void DaliApplicationController::reset() { + config_.applicationControllerEnabled = true; + powerCycleNotificationEnabled_ = true; + resetState_ = true; + operatingMode_ = 0; + dtr0_ = dtr1_ = dtr2_ = 0; +} + +std::optional DaliApplicationController::queryResponse(uint8_t opcode) const { + switch (opcode) { + case 0x30: { + uint8_t status = 0; + // Bit 2 reports that the device currently has a short-address *mask*, + // rather than a commissioned short address. + if (!config_.shortAddress.has_value()) status |= 0x04; + if (config_.applicationControllerEnabled) status |= 0x08; + if (powerCycleSeen_) status |= 0x20; + if (resetState_) status |= 0x40; + return status; + } + case 0x31: + case 0x32: + return 0x00; + case 0x33: + return static_cast(config_.shortAddress.has_value() ? 0x00 : 0xFF); + case 0x34: + return config_.versionNumber; + case 0x35: + return 0x00; + case 0x36: + return dtr0_; + case 0x37: + return dtr1_; + case 0x38: + return dtr2_; + case 0x39: + return static_cast((config_.randomAddress >> 16) & 0xFFU); + case 0x3A: + return static_cast((config_.randomAddress >> 8) & 0xFFU); + case 0x3B: + return static_cast(config_.randomAddress & 0xFFU); + case 0x3C: + return 0xFF; + case 0x3D: + return static_cast(config_.applicationControllerEnabled ? 0xFF : 0x00); + case 0x3E: + return operatingMode_; + case 0x3F: + case 0x40: + case 0x41: + case 0x42: + case 0x43: + case 0x44: + return 0x00; + case 0x45: + return static_cast(powerCycleNotificationEnabled_ ? 0xFF : 0x00); + case 0x46: { + uint8_t capabilities = 0x01; + if (config_.applicationControllerAlwaysActive) capabilities |= 0x04; + return capabilities; + } + case 0x47: + return config_.extendedVersionNumber; + case 0x48: + return static_cast(resetState_ ? 0xFF : 0x00); + default: + return std::nullopt; + } +} + +DaliApplicationControllerResult DaliApplicationController::handleForwardFrame( + const std::array& frame, bool doubleSendConfirmed) { + DaliApplicationControllerResult result; + const uint8_t address = frame[0]; + const uint8_t instance = frame[1]; + const uint8_t opcode = frame[2]; + if (IsDoubleSendCommand(frame) && !doubleSendConfirmed) return result; + + if (address == 0xC1) { + switch (instance) { + case 0x00: + initialising_ = false; + withdrawn_ = false; + break; + case 0x01: + if (opcode == 0xFF || + (config_.shortAddress.has_value() && + opcode == static_cast((*config_.shortAddress << 1) | 1U))) { + initialising_ = true; + withdrawn_ = false; + } + break; + case 0x02: + if (initialising_) { + // Deterministic re-randomisation lets a host provide/persist the + // seed without this library depending on an RNG or a clock. + config_.randomAddress = + (config_.randomAddress * 1103515245U + 12345U) & 0xFFFFFFU; + searchAddress_ = config_.randomAddress; + withdrawn_ = false; + } + break; + case 0x03: + if (initialising_ && !withdrawn_ && config_.randomAddress <= searchAddress()) { + result.backwardFrame = 0xFF; + } + break; + case 0x04: + if (isSelectedForCommissioning()) withdrawn_ = true; + break; + case 0x05: + searchAddress_ = + (searchAddress_ & 0x00FFFFU) | (static_cast(opcode) << 16); + break; + case 0x06: + searchAddress_ = + (searchAddress_ & 0xFF00FFU) | (static_cast(opcode) << 8); + break; + case 0x07: + searchAddress_ = (searchAddress_ & 0xFFFF00U) | opcode; + break; + case 0x08: + if (isSelectedForCommissioning()) { + const std::optional next = + opcode == 0xFF + ? std::nullopt + : std::optional(static_cast((opcode >> 1) & 0x3FU)); + if (next != config_.shortAddress) { + setShortAddress(next); + result.shortAddressChanged = true; + result.shortAddress = config_.shortAddress; + } + } + break; + case 0x09: + if (initialising_ && config_.shortAddress.has_value() && + opcode == static_cast((*config_.shortAddress << 1) | 1U)) { + result.backwardFrame = 0xFF; + } + break; + case 0x0A: + if (initialising_ && isSelectedForCommissioning() && config_.shortAddress.has_value()) { + result.backwardFrame = static_cast((*config_.shortAddress << 1) | 1U); + } + break; + case 0x30: + dtr0_ = opcode; + break; + case 0x31: + dtr1_ = opcode; + break; + case 0x32: + dtr2_ = opcode; + break; + default: + break; + } + return result; + } + + if (!isAddressed(frame) || instance != 0xFE) return result; + switch (opcode) { + case 0x00: + result.identifyRequested = true; + break; + case 0x01: + powerCycleSeen_ = false; + resetState_ = false; + break; + case 0x10: + reset(); + break; + case 0x16: + config_.applicationControllerEnabled = true; + resetState_ = false; + break; + case 0x17: + if (!config_.applicationControllerAlwaysActive) { + config_.applicationControllerEnabled = false; + } + resetState_ = false; + break; + case 0x18: + operatingMode_ = dtr0_; + resetState_ = false; + break; + case 0x1F: + powerCycleNotificationEnabled_ = true; + resetState_ = false; + break; + case 0x20: + powerCycleNotificationEnabled_ = false; + resetState_ = false; + break; + case 0x21: + resetState_ = false; + break; + default: + break; + } + result.backwardFrame = queryResponse(opcode); + return result; +} diff --git a/src/dali_gateway_cache.cpp b/src/dali_gateway_cache.cpp new file mode 100644 index 0000000..6ffcba4 --- /dev/null +++ b/src/dali_gateway_cache.cpp @@ -0,0 +1,359 @@ +#include "dali_gateway.hpp" + +#include +#include + +namespace { + +constexpr int kAddressStatePayloadVersion = 1; +constexpr uint32_t kGroupMaskKnown = 1U << 0; +constexpr uint32_t kActualLevelKnown = 1U << 1; +constexpr uint32_t kSceneKnown = 1U << 2; +constexpr uint32_t kUseMinLevel = 1U << 3; +constexpr uint32_t kStatusStale = 1U << 4; +constexpr uint32_t kPowerOnKnown = 1U << 5; +constexpr uint32_t kSystemFailureKnown = 1U << 6; +constexpr uint32_t kMinKnown = 1U << 7; +constexpr uint32_t kMaxKnown = 1U << 8; +constexpr uint32_t kFadeTimeKnown = 1U << 9; +constexpr uint32_t kFadeRateKnown = 1U << 10; + +std::vector ParseCsv(std::string_view raw) { + std::vector values; + size_t start = 0; + while (start < raw.size()) { + const size_t comma = raw.find(',', start); + const size_t end = comma == std::string_view::npos ? raw.size() : comma; + if (end > start) { + values.push_back(static_cast( + std::strtol(std::string(raw.substr(start, end - start)).c_str(), nullptr, 10))); + } + if (comma == std::string_view::npos) break; + start = comma + 1; + } + return values; +} + +uint8_t ByteValue(int value) { + return static_cast(std::clamp(value, 0, 255)); +} + +uint16_t WordValue(int value) { + return static_cast(std::clamp(value, 0, 0xffff)); +} + +uint16_t SceneKnownMask(const DaliGatewayAddressState& state) { + uint16_t mask = 0; + for (size_t index = 0; index < state.sceneLevels.size(); ++index) { + if (state.sceneLevels[index].has_value()) { + mask |= static_cast(1U << index); + } + } + return mask; +} + +uint32_t StateFlags(const DaliGatewayAddressState& state) { + uint32_t flags = 0; + if (state.groupMaskKnown) flags |= kGroupMaskKnown; + if (state.status.actualLevel.has_value()) flags |= kActualLevelKnown; + if (state.status.sceneID.has_value()) flags |= kSceneKnown; + if (state.status.useMinLevel) flags |= kUseMinLevel; + if (state.status.stale) flags |= kStatusStale; + if (state.settings.powerOnLevel.has_value()) flags |= kPowerOnKnown; + if (state.settings.systemFailureLevel.has_value()) flags |= kSystemFailureKnown; + if (state.settings.minLevel.has_value()) flags |= kMinKnown; + if (state.settings.maxLevel.has_value()) flags |= kMaxKnown; + if (state.settings.fadeTime.has_value()) flags |= kFadeTimeKnown; + if (state.settings.fadeRate.has_value()) flags |= kFadeRateKnown; + return flags; +} + +bool IsDefaultState(const DaliGatewayAddressState& state) { + return !state.groupMaskKnown && state.groupMask == 0 && SceneKnownMask(state) == 0 && + !state.settings.anyKnown() && !state.status.anyKnown(); +} + +} // namespace + +DaliGatewayCache::DaliGatewayCache(DaliGatewayCacheConfig config) : config_(config) {} + +void DaliGatewayCache::configure(DaliGatewayCacheConfig config) { + std::lock_guard guard(mutex_); + config_ = config; +} + +bool DaliGatewayCache::enabled() const { + std::lock_guard guard(mutex_); + return config_.enabled; +} + +bool DaliGatewayCache::setEnabled(bool enabled) { + std::lock_guard guard(mutex_); + config_.enabled = enabled; + return true; +} + +bool DaliGatewayCache::reconciliationEnabled() const { + std::lock_guard guard(mutex_); + return config_.enabled && config_.reconciliationEnabled; +} + +bool DaliGatewayCache::fullStateMirrorEnabled() const { + std::lock_guard guard(mutex_); + return config_.enabled && config_.reconciliationEnabled && config_.fullStateMirrorEnabled; +} + +DaliGatewayCachePriorityMode DaliGatewayCache::priorityMode() const { + std::lock_guard guard(mutex_); + return config_.priorityMode; +} + +void DaliGatewayCache::setPriorityMode(DaliGatewayCachePriorityMode mode) { + std::lock_guard guard(mutex_); + config_.priorityMode = mode; +} + +void DaliGatewayCache::setStatusUpdateCallback(StatusUpdateCallback callback) { + std::lock_guard guard(mutex_); + statusUpdateCallback_ = std::move(callback); +} + +void DaliGatewayCache::setPersistenceCallbacks( + DaliGatewayCachePersistenceCallbacks callbacks) { + std::lock_guard guard(mutex_); + persistence_ = std::move(callbacks); +} + +DaliGatewayCache::AddressStates& DaliGatewayCache::ensureStates(uint8_t channel) { + return states_[channel]; +} + +DaliGatewayCache::PresenceStates& DaliGatewayCache::ensurePresence(uint8_t channel) { + return presence_[channel]; +} + +DaliGatewayCache::GroupStatuses& DaliGatewayCache::ensureGroupStatuses(uint8_t channel) { + return groupStatuses_[channel]; +} + +DaliGatewayRuntimeStatus& DaliGatewayCache::ensureBroadcastStatus(uint8_t channel) { + return broadcastStatuses_[channel]; +} + +uint32_t DaliGatewayCache::nextRevision() { + ++revision_; + if (revision_ == 0) ++revision_; + return revision_; +} + +void DaliGatewayCache::markDirty(uint8_t channel) { + auto& generation = dirtyGenerations_[channel]; + ++generation; + if (generation == 0) ++generation; +} + +DaliGatewayAddressState DaliGatewayCache::addressState(uint8_t channel, + uint8_t shortAddress) const { + std::lock_guard guard(mutex_); + if (shortAddress >= 64) return {}; + const auto it = states_.find(channel); + return it == states_.end() ? DaliGatewayAddressState{} : it->second[shortAddress]; +} + +DaliGatewayPresence DaliGatewayCache::addressPresence(uint8_t channel, + uint8_t shortAddress) const { + std::lock_guard guard(mutex_); + if (shortAddress >= 64) return DaliGatewayPresence::unknown; + const auto it = presence_.find(channel); + return it == presence_.end() ? DaliGatewayPresence::unknown : it->second[shortAddress]; +} + +DaliGatewayRuntimeStatus DaliGatewayCache::groupStatus(uint8_t channel, uint8_t group) const { + std::lock_guard guard(mutex_); + if (group >= 16) return {}; + const auto it = groupStatuses_.find(channel); + return it == groupStatuses_.end() ? DaliGatewayRuntimeStatus{} : it->second[group]; +} + +DaliGatewayRuntimeStatus DaliGatewayCache::broadcastStatus(uint8_t channel) const { + std::lock_guard guard(mutex_); + const auto it = broadcastStatuses_.find(channel); + return it == broadcastStatuses_.end() ? DaliGatewayRuntimeStatus{} : it->second; +} + +DaliGatewayChannelFlags DaliGatewayCache::channelFlags(uint8_t channel) const { + std::lock_guard guard(mutex_); + const auto it = flags_.find(channel); + return it == flags_.end() ? DaliGatewayChannelFlags{} : it->second; +} + +DaliGatewayChannelFlags DaliGatewayCache::pendingChannelFlags(uint8_t channel) const { + return channelFlags(channel); +} + +void DaliGatewayCache::markAddressPresence(uint8_t channel, uint8_t shortAddress, + DaliGatewayPresence presence) { + std::lock_guard guard(mutex_); + if (shortAddress >= 64) return; + ensurePresence(channel)[shortAddress] = presence; +} + +std::array DaliGatewayCache::addressStates(uint8_t channel) const { + std::lock_guard guard(mutex_); + const auto it = states_.find(channel); + return it == states_.end() ? std::array{} : it->second; +} + +void DaliGatewayCache::restoreAddressStates( + uint8_t channel, const std::array& states) { + std::lock_guard guard(mutex_); + states_[channel] = states; + for (auto& state : states_[channel]) { + if (state.status.anyKnown()) state.status.stale = true; + revision_ = std::max(revision_, state.status.revision); + } +} + +std::optional DaliGatewayCache::statusUpdate( + uint8_t channel, const DaliGatewayTarget& target) const { + DaliGatewayStatusUpdate update; + update.channel = channel; + update.target = target; + const auto states = states_.find(channel); + if (target.kind == DaliGatewayTargetKind::shortAddress) { + if (target.value >= 64 || states == states_.end()) return std::nullopt; + update.status = states->second[target.value].status; + update.affectedShortAddresses.push_back(target.value); + } else if (target.kind == DaliGatewayTargetKind::group) { + if (target.value >= 16) return std::nullopt; + const auto groups = groupStatuses_.find(channel); + if (groups != groupStatuses_.end()) update.status = groups->second[target.value]; + if (states != states_.end()) { + const uint16_t bit = static_cast(1U << target.value); + for (uint8_t address = 0; address < states->second.size(); ++address) { + const auto& state = states->second[address]; + if (state.groupMaskKnown && (state.groupMask & bit) != 0) { + update.affectedShortAddresses.push_back(address); + } + } + } + } else { + const auto broadcast = broadcastStatuses_.find(channel); + if (broadcast != broadcastStatuses_.end()) update.status = broadcast->second; + for (uint8_t address = 0; address < 64; ++address) { + update.affectedShortAddresses.push_back(address); + } + } + return update.status.anyKnown() ? std::optional(std::move(update)) + : std::nullopt; +} + +std::optional DaliGatewayCache::encodeAddressState( + const DaliGatewayAddressState& state) { + if (IsDefaultState(state)) return std::nullopt; + std::string payload = std::to_string(kAddressStatePayloadVersion); + payload += "," + std::to_string(StateFlags(state)); + payload += "," + std::to_string(state.status.revision); + payload += "," + std::to_string(state.groupMask); + payload += "," + std::to_string(state.status.actualLevel.value_or(0)); + payload += "," + std::to_string(state.status.sceneID.value_or(0)); + payload += "," + std::to_string(state.settings.powerOnLevel.value_or(0)); + payload += "," + std::to_string(state.settings.systemFailureLevel.value_or(0)); + payload += "," + std::to_string(state.settings.minLevel.value_or(0)); + payload += "," + std::to_string(state.settings.maxLevel.value_or(0)); + payload += "," + std::to_string(state.settings.fadeTime.value_or(0)); + payload += "," + std::to_string(state.settings.fadeRate.value_or(0)); + payload += "," + std::to_string(SceneKnownMask(state)); + for (const auto& level : state.sceneLevels) { + payload += "," + std::to_string(level.value_or(255)); + } + return payload; +} + +std::optional DaliGatewayCache::decodeAddressState( + std::string_view payload) { + const auto values = ParseCsv(payload); + if (values.size() < 13 || values[0] != kAddressStatePayloadVersion) return std::nullopt; + + DaliGatewayAddressState state; + const uint32_t flags = static_cast(std::max(values[1], 0)); + state.groupMaskKnown = (flags & kGroupMaskKnown) != 0; + state.groupMask = state.groupMaskKnown ? WordValue(values[3]) : 0; + state.status.revision = static_cast(std::max(values[2], 0)); + state.status.stale = (flags & kStatusStale) != 0; + state.status.useMinLevel = (flags & kUseMinLevel) != 0; + if ((flags & kActualLevelKnown) != 0) state.status.actualLevel = ByteValue(values[4]); + if ((flags & kSceneKnown) != 0) { + state.status.sceneID = static_cast(std::min(ByteValue(values[5]), 15)); + } + if ((flags & kPowerOnKnown) != 0) state.settings.powerOnLevel = ByteValue(values[6]); + if ((flags & kSystemFailureKnown) != 0) { + state.settings.systemFailureLevel = ByteValue(values[7]); + } + if ((flags & kMinKnown) != 0) state.settings.minLevel = ByteValue(values[8]); + if ((flags & kMaxKnown) != 0) state.settings.maxLevel = ByteValue(values[9]); + if ((flags & kFadeTimeKnown) != 0) state.settings.fadeTime = ByteValue(values[10]); + if ((flags & kFadeRateKnown) != 0) state.settings.fadeRate = ByteValue(values[11]); + + const uint16_t knownScenes = WordValue(values[12]); + for (uint8_t scene = 0; scene < state.sceneLevels.size(); ++scene) { + const size_t valueIndex = 13 + scene; + if ((knownScenes & (1U << scene)) != 0 && valueIndex < values.size()) { + state.sceneLevels[scene] = ByteValue(values[valueIndex]); + } + } + return state; +} + +bool DaliGatewayCache::preloadChannel(uint8_t channel) { + DaliGatewayCachePersistenceCallbacks persistence; + { + std::lock_guard guard(mutex_); + persistence = persistence_; + } + if (!persistence.load) return false; + + AddressStates restored{}; + for (uint8_t address = 0; address < restored.size(); ++address) { + const auto payload = persistence.load(channel, address); + if (!payload.has_value()) continue; + const auto state = decodeAddressState(*payload); + if (state.has_value()) restored[address] = *state; + } + restoreAddressStates(channel, restored); + return true; +} + +bool DaliGatewayCache::flush() { + DaliGatewayCachePersistenceCallbacks persistence; + std::map snapshots; + std::map generations; + { + std::lock_guard guard(mutex_); + if (dirtyGenerations_.empty()) return true; + persistence = persistence_; + if (!persistence.store || !persistence.commit) return false; + for (const auto& [channel, generation] : dirtyGenerations_) { + snapshots[channel] = ensureStates(channel); + generations[channel] = generation; + } + } + + for (const auto& [channel, states] : snapshots) { + for (uint8_t address = 0; address < states.size(); ++address) { + if (!persistence.store(channel, address, encodeAddressState(states[address]))) { + return false; + } + } + } + if (!persistence.commit()) return false; + + std::lock_guard guard(mutex_); + for (const auto& [channel, generation] : generations) { + const auto current = dirtyGenerations_.find(channel); + if (current != dirtyGenerations_.end() && current->second == generation) { + dirtyGenerations_.erase(current); + } + } + return true; +} diff --git a/src/dali_gateway_cache_mutation.cpp b/src/dali_gateway_cache_mutation.cpp new file mode 100644 index 0000000..4eb5aac --- /dev/null +++ b/src/dali_gateway_cache_mutation.cpp @@ -0,0 +1,401 @@ +#include "dali_gateway.hpp" + +namespace { + +constexpr uint8_t kCmdOff = 0x00; +constexpr uint8_t kCmdRecallMax = 0x05; +constexpr uint8_t kCmdRecallMin = 0x06; +constexpr uint8_t kCmdGoToSceneMin = 0x10; +constexpr uint8_t kCmdGoToSceneMax = 0x1F; +constexpr uint8_t kCmdReset = 0x20; +constexpr uint8_t kCmdStoreDtrAsMax = 0x2A; +constexpr uint8_t kCmdStoreDtrAsMin = 0x2B; +constexpr uint8_t kCmdStoreDtrAsFailure = 0x2C; +constexpr uint8_t kCmdStoreDtrAsPowerOn = 0x2D; +constexpr uint8_t kCmdStoreDtrAsFadeTime = 0x2E; +constexpr uint8_t kCmdStoreDtrAsFadeRate = 0x2F; +constexpr uint8_t kCmdSetSceneMin = 0x40; +constexpr uint8_t kCmdRemoveSceneMax = 0x5F; +constexpr uint8_t kCmdAddToGroupMin = 0x60; +constexpr uint8_t kCmdRemoveFromGroupMax = 0x7F; +constexpr uint8_t kCmdSetDtr0 = 0xA3; +constexpr uint8_t kCmdSetDtr1 = 0xC3; +constexpr uint8_t kCmdSetDtr2 = 0xC5; + +void ClearAddress(DaliGatewayAddressState& state) { + state.groupMaskKnown = false; + state.groupMask = 0; + state.sceneLevels.fill(std::nullopt); + state.settings = {}; + state.status = {}; +} + +void ApplySettingsValue(DaliGatewaySettingsSnapshot& settings, uint8_t command, uint8_t value) { + switch (command) { + case kCmdStoreDtrAsMax: + settings.maxLevel = value; + break; + case kCmdStoreDtrAsMin: + settings.minLevel = value; + break; + case kCmdStoreDtrAsFailure: + settings.systemFailureLevel = value; + break; + case kCmdStoreDtrAsPowerOn: + settings.powerOnLevel = value; + break; + case kCmdStoreDtrAsFadeTime: + settings.fadeTime = value; + break; + case kCmdStoreDtrAsFadeRate: + settings.fadeRate = value; + break; + default: + break; + } +} + +} // namespace + +void DaliGatewayCache::applyRuntimeStatusToAddress(DaliGatewayAddressState& address, + const DaliGatewayRuntimeStatus& status) { + if (!status.anyKnown() || status.revision <= address.status.revision) return; + if (status.sceneID.has_value()) { + address.status.sceneID = status.sceneID; + address.status.useMinLevel = false; + const uint8_t scene = *status.sceneID; + if (scene < address.sceneLevels.size() && address.sceneLevels[scene].has_value() && + *address.sceneLevels[scene] != 255U) { + address.status.actualLevel = *address.sceneLevels[scene]; + } else if (status.actualLevel.has_value()) { + address.status.actualLevel = status.actualLevel; + } + } else { + address.status.sceneID.reset(); + address.status.useMinLevel = status.useMinLevel; + address.status.actualLevel = status.useMinLevel ? address.settings.minLevel : status.actualLevel; + } + address.status.revision = status.revision; + address.status.stale = status.stale; +} + +void DaliGatewayCache::applyRuntimeStatus(uint8_t channel, const DaliGatewayTarget& target, + const DaliGatewayRuntimeStatus& status) { + if (!status.anyKnown()) return; + auto& states = ensureStates(channel); + switch (target.kind) { + case DaliGatewayTargetKind::shortAddress: + if (target.value < states.size()) applyRuntimeStatusToAddress(states[target.value], status); + break; + case DaliGatewayTargetKind::group: { + if (target.value >= 16) break; + ensureGroupStatuses(channel)[target.value] = status; + const uint16_t bit = static_cast(1U << target.value); + for (auto& address : states) { + if (address.groupMaskKnown && (address.groupMask & bit) != 0) { + applyRuntimeStatusToAddress(address, status); + } + } + break; + } + case DaliGatewayTargetKind::broadcast: + ensureBroadcastStatus(channel) = status; + for (auto& address : states) applyRuntimeStatusToAddress(address, status); + break; + } +} + +void DaliGatewayCache::refreshAggregateStatus(uint8_t channel, DaliGatewayAddressState& address) { + if (!address.groupMaskKnown) return; + if (const auto broadcast = broadcastStatuses_.find(channel); + broadcast != broadcastStatuses_.end() && !broadcast->second.stale) { + applyRuntimeStatusToAddress(address, broadcast->second); + } + const auto groups = groupStatuses_.find(channel); + if (groups == groupStatuses_.end()) return; + for (uint8_t group = 0; group < groups->second.size(); ++group) { + if ((address.groupMask & static_cast(1U << group)) != 0 && + !groups->second[group].stale) { + applyRuntimeStatusToAddress(address, groups->second[group]); + } + } +} + +bool DaliGatewayCache::setGroupMask(uint8_t channel, uint8_t shortAddress, + std::optional groupMask) { + StatusUpdateCallback callback; + std::optional update; + { + std::lock_guard guard(mutex_); + if (!config_.enabled || shortAddress >= 64) return false; + auto& address = ensureStates(channel)[shortAddress]; + if (!groupMask.has_value()) { + address.groupMaskKnown = false; + address.groupMask = 0; + } else { + address.groupMaskKnown = true; + address.groupMask = *groupMask; + refreshAggregateStatus(channel, address); + } + markDirty(channel); + update = statusUpdate( + channel, DaliGatewayTarget{DaliGatewayTargetKind::shortAddress, shortAddress}); + callback = statusUpdateCallback_; + } + if (callback && update.has_value()) callback(*update); + return true; +} + +bool DaliGatewayCache::setSceneLevel(uint8_t channel, uint8_t shortAddress, uint8_t scene, + std::optional level) { + StatusUpdateCallback callback; + std::optional update; + { + std::lock_guard guard(mutex_); + if (!config_.enabled || shortAddress >= 64 || scene >= 16) return false; + auto& address = ensureStates(channel)[shortAddress]; + address.sceneLevels[scene] = level; + if (address.status.sceneID.has_value() && *address.status.sceneID == scene && + level.has_value() && *level != 255U) { + address.status.actualLevel = level; + } + markDirty(channel); + update = statusUpdate( + channel, DaliGatewayTarget{DaliGatewayTargetKind::shortAddress, shortAddress}); + callback = statusUpdateCallback_; + } + if (callback && update.has_value()) callback(*update); + return true; +} + +bool DaliGatewayCache::setSettings(uint8_t channel, uint8_t shortAddress, + std::optional settings) { + StatusUpdateCallback callback; + std::optional update; + { + std::lock_guard guard(mutex_); + if (!config_.enabled || shortAddress >= 64) return false; + auto& address = ensureStates(channel)[shortAddress]; + address.settings = settings.value_or(DaliGatewaySettingsSnapshot{}); + if (address.status.useMinLevel) address.status.actualLevel = address.settings.minLevel; + markDirty(channel); + update = statusUpdate( + channel, DaliGatewayTarget{DaliGatewayTargetKind::shortAddress, shortAddress}); + callback = statusUpdateCallback_; + } + if (callback && update.has_value()) callback(*update); + return true; +} + +bool DaliGatewayCache::setActualLevel(uint8_t channel, uint8_t shortAddress, + std::optional level) { + StatusUpdateCallback callback; + std::optional update; + { + std::lock_guard guard(mutex_); + if (!config_.enabled || shortAddress >= 64) return false; + auto& address = ensureStates(channel)[shortAddress]; + address.status.actualLevel = level; + address.status.sceneID.reset(); + address.status.useMinLevel = false; + address.status.stale = false; + address.status.revision = nextRevision(); + markDirty(channel); + update = statusUpdate( + channel, DaliGatewayTarget{DaliGatewayTargetKind::shortAddress, shortAddress}); + callback = statusUpdateCallback_; + } + if (callback && update.has_value()) callback(*update); + return true; +} + +void DaliGatewayCache::clearTarget(uint8_t channel, const DaliGatewayTarget& target, + uint32_t revision) { + auto& states = ensureStates(channel); + const auto clear = [revision](DaliGatewayAddressState& address) { + ClearAddress(address); + address.status.revision = revision; + }; + switch (target.kind) { + case DaliGatewayTargetKind::shortAddress: + if (target.value < states.size()) clear(states[target.value]); + break; + case DaliGatewayTargetKind::group: { + if (target.value >= 16) break; + ensureGroupStatuses(channel)[target.value] = DaliGatewayRuntimeStatus{}; + ensureGroupStatuses(channel)[target.value].revision = revision; + const uint16_t bit = static_cast(1U << target.value); + for (auto& address : states) { + if (address.groupMaskKnown && (address.groupMask & bit) != 0) clear(address); + } + break; + } + case DaliGatewayTargetKind::broadcast: + ensureBroadcastStatus(channel) = DaliGatewayRuntimeStatus{}; + ensureBroadcastStatus(channel).revision = revision; + for (auto& status : ensureGroupStatuses(channel)) status = DaliGatewayRuntimeStatus{}; + for (auto& address : states) clear(address); + break; + } +} + +void DaliGatewayCache::applyGroupMutation(uint8_t channel, const DaliGatewayTarget& target, + uint8_t group, bool add) { + if (group >= 16) return; + auto& states = ensureStates(channel); + const uint16_t bit = static_cast(1U << group); + const auto apply = [&](DaliGatewayAddressState& address) { + if (!address.groupMaskKnown) return; + address.groupMask = add ? static_cast(address.groupMask | bit) + : static_cast(address.groupMask & ~bit); + refreshAggregateStatus(channel, address); + }; + if (target.kind == DaliGatewayTargetKind::shortAddress && target.value < states.size()) { + apply(states[target.value]); + } else if (target.kind == DaliGatewayTargetKind::group && target.value < 16) { + const uint16_t targetBit = static_cast(1U << target.value); + for (auto& address : states) { + if (address.groupMaskKnown && (address.groupMask & targetBit) != 0) apply(address); + } + } else if (target.kind == DaliGatewayTargetKind::broadcast) { + for (auto& address : states) apply(address); + } +} + +void DaliGatewayCache::applySceneMutation(uint8_t channel, const DaliGatewayTarget& target, + uint8_t scene, std::optional level) { + if (scene >= 16) return; + auto& states = ensureStates(channel); + const auto apply = [&](DaliGatewayAddressState& address) { + address.sceneLevels[scene] = level; + if (address.status.sceneID.has_value() && *address.status.sceneID == scene && + level.has_value() && *level != 255U) { + address.status.actualLevel = level; + } + }; + if (target.kind == DaliGatewayTargetKind::shortAddress && target.value < states.size()) { + apply(states[target.value]); + } else if (target.kind == DaliGatewayTargetKind::group && target.value < 16) { + const uint16_t bit = static_cast(1U << target.value); + for (auto& address : states) { + if (address.groupMaskKnown && (address.groupMask & bit) != 0) apply(address); + } + } else if (target.kind == DaliGatewayTargetKind::broadcast) { + for (auto& address : states) apply(address); + } +} + +void DaliGatewayCache::applySettingsMutation(uint8_t channel, const DaliGatewayTarget& target, + uint8_t command, uint8_t value) { + auto& states = ensureStates(channel); + const auto apply = [&](DaliGatewayAddressState& address) { + ApplySettingsValue(address.settings, command, value); + if (command == kCmdStoreDtrAsMin && address.status.useMinLevel) { + address.status.actualLevel = value; + } + }; + if (target.kind == DaliGatewayTargetKind::shortAddress && target.value < states.size()) { + apply(states[target.value]); + } else if (target.kind == DaliGatewayTargetKind::group && target.value < 16) { + const uint16_t bit = static_cast(1U << target.value); + for (auto& address : states) { + if (address.groupMaskKnown && (address.groupMask & bit) != 0) apply(address); + } + } else if (target.kind == DaliGatewayTargetKind::broadcast) { + for (auto& address : states) apply(address); + } +} + +bool DaliGatewayCache::mirrorForwardFrameLocked( + uint8_t channel, uint8_t rawAddress, uint8_t command, + std::optional* statusUpdateResult) { + if (!config_.enabled) return false; + auto& dtr = dtrStates_[channel]; + if (rawAddress == kCmdSetDtr0) { + dtr.dtr0 = command; + return false; + } + if (rawAddress == kCmdSetDtr1) { + dtr.dtr1 = command; + return false; + } + if (rawAddress == kCmdSetDtr2) { + dtr.dtr2 = command; + return false; + } + const auto target = decodeTarget(rawAddress); + if (!target.has_value()) return false; + const auto finish = [&]() { + markDirty(channel); + if (statusUpdateResult != nullptr) { + *statusUpdateResult = statusUpdate(channel, *target); + } + return true; + }; + if ((rawAddress & 1U) == 0) { + if (command > 254) return false; + DaliGatewayRuntimeStatus status; + status.actualLevel = command; + status.revision = nextRevision(); + applyRuntimeStatus(channel, *target, status); + return finish(); + } + if (command == kCmdReset) { + clearTarget(channel, *target, nextRevision()); + return finish(); + } + if (command == kCmdOff || command == kCmdRecallMax) { + DaliGatewayRuntimeStatus status; + status.actualLevel = command == kCmdOff ? 0 : 254; + status.revision = nextRevision(); + applyRuntimeStatus(channel, *target, status); + return finish(); + } + if (command == kCmdRecallMin) { + DaliGatewayRuntimeStatus status; + status.useMinLevel = true; + status.revision = nextRevision(); + applyRuntimeStatus(channel, *target, status); + return finish(); + } + if (command >= kCmdGoToSceneMin && command <= kCmdGoToSceneMax) { + DaliGatewayRuntimeStatus status; + status.sceneID = static_cast(command - kCmdGoToSceneMin); + status.revision = nextRevision(); + applyRuntimeStatus(channel, *target, status); + return finish(); + } + if (command >= kCmdAddToGroupMin && command <= kCmdRemoveFromGroupMax) { + applyGroupMutation(channel, *target, command & 0x0F, command < kCmdAddToGroupMin + 16); + return finish(); + } + if (command >= kCmdSetSceneMin && command < kCmdSetSceneMin + 16 && dtr.dtr0.has_value()) { + applySceneMutation(channel, *target, command - kCmdSetSceneMin, dtr.dtr0); + return finish(); + } + if (command >= kCmdSetSceneMin + 16 && command <= kCmdRemoveSceneMax) { + applySceneMutation(channel, *target, command - (kCmdSetSceneMin + 16), + static_cast(255)); + return finish(); + } + if (command >= kCmdStoreDtrAsMax && command <= kCmdStoreDtrAsFadeRate && + dtr.dtr0.has_value()) { + applySettingsMutation(channel, *target, command, *dtr.dtr0); + return finish(); + } + return false; +} + +bool DaliGatewayCache::mirrorForwardFrame(uint8_t channel, uint8_t rawAddress, + uint8_t command) { + StatusUpdateCallback callback; + std::optional update; + bool changed = false; + { + std::lock_guard guard(mutex_); + changed = mirrorForwardFrameLocked(channel, rawAddress, command, &update); + callback = statusUpdateCallback_; + } + if (callback && update.has_value()) callback(*update); + return changed; +} diff --git a/src/dali_gateway_reconciliation.cpp b/src/dali_gateway_reconciliation.cpp new file mode 100644 index 0000000..e7d182b --- /dev/null +++ b/src/dali_gateway_reconciliation.cpp @@ -0,0 +1,141 @@ +#include "dali_gateway.hpp" + +namespace { + +constexpr uint8_t kCmdOff = 0x00; +constexpr uint8_t kCmdRecallMax = 0x05; +constexpr uint8_t kCmdRecallMin = 0x06; +constexpr uint8_t kCmdGoToSceneMin = 0x10; +constexpr uint8_t kCmdGoToSceneMax = 0x1F; +constexpr uint8_t kCmdReset = 0x20; +constexpr uint8_t kCmdStoreDtrAsMax = 0x2A; +constexpr uint8_t kCmdStoreDtrAsFadeRate = 0x2F; +constexpr uint8_t kCmdSetSceneMin = 0x40; +constexpr uint8_t kCmdRemoveSceneMax = 0x5F; +constexpr uint8_t kCmdAddToGroupMin = 0x60; +constexpr uint8_t kCmdRemoveFromGroupMax = 0x7F; +constexpr uint8_t kCmdProgramShortAddress = 0xB7; +constexpr uint8_t kCmdDt8StoreColorX = 0xE0; +constexpr uint8_t kCmdDt8StoreColorY = 0xE1; +constexpr uint8_t kCmdDt8StorePrimaryMin = 0xF0; +constexpr uint8_t kCmdDt8StartCalibration = 0xF6; + +DaliGatewayChannelFlags ClassifyMutation(uint8_t rawAddress, uint8_t command) { + DaliGatewayChannelFlags flags; + if (rawAddress == kCmdProgramShortAddress) { + flags.needUpdateSettings = true; + return flags; + } + const bool special = rawAddress >= 0xA1 && rawAddress <= 0xC5 && (rawAddress & 1U) != 0; + if (special || (rawAddress & 1U) == 0) return flags; + if (command == kCmdReset) { + flags = {true, true, true}; + } else if (command >= kCmdStoreDtrAsMax && command <= kCmdStoreDtrAsFadeRate) { + flags.needUpdateSettings = true; + } else if (command >= kCmdSetSceneMin && command <= kCmdRemoveSceneMax) { + flags.needUpdateScene = true; + } else if (command >= kCmdAddToGroupMin && command <= kCmdRemoveFromGroupMax) { + flags.needUpdateGroup = true; + } else if (command == 0x80 || command == kCmdDt8StoreColorX || command == kCmdDt8StoreColorY || + (command >= kCmdDt8StorePrimaryMin && command <= kCmdDt8StartCalibration)) { + flags.needUpdateSettings = true; + } + return flags; +} + +bool SameFlags(const DaliGatewayChannelFlags& lhs, const DaliGatewayChannelFlags& rhs) { + return lhs.needUpdateGroup == rhs.needUpdateGroup && + lhs.needUpdateScene == rhs.needUpdateScene && + lhs.needUpdateSettings == rhs.needUpdateSettings; +} + +bool IsStatusFeedback(uint8_t rawAddress, uint8_t command) { + if (!DaliGatewayCache::decodeTarget(rawAddress).has_value()) return false; + if ((rawAddress & 1U) == 0) return command <= 254; + return command == kCmdOff || command == kCmdRecallMax || command == kCmdRecallMin || + (command >= kCmdGoToSceneMin && command <= kCmdGoToSceneMax); +} + +} // namespace + +bool DaliGatewayCache::observeForwardFrame(uint8_t channel, uint8_t rawAddress, uint8_t command, + DaliGatewayFrameOrigin origin) { + StatusUpdateCallback callback; + std::optional update; + bool flagged = false; + { + std::lock_guard guard(mutex_); + if (!config_.enabled || !config_.reconciliationEnabled) return false; + const auto mutation = ClassifyMutation(rawAddress, command); + const bool mirrorMutation = + origin == DaliGatewayFrameOrigin::localGateway || + config_.priorityMode == DaliGatewayCachePriorityMode::outsideBusFirst; + if (mirrorMutation || IsStatusFeedback(rawAddress, command)) { + mirrorForwardFrameLocked(channel, rawAddress, command, &update); + } + if (mutation.any()) { + auto& flags = flags_[channel]; + flags.needUpdateGroup = flags.needUpdateGroup || mutation.needUpdateGroup; + flags.needUpdateScene = flags.needUpdateScene || mutation.needUpdateScene; + flags.needUpdateSettings = flags.needUpdateSettings || mutation.needUpdateSettings; + flagged = true; + } + callback = statusUpdateCallback_; + } + if (callback && update.has_value()) callback(*update); + return flagged; +} + +std::vector DaliGatewayCache::reconciliationAddresses( + uint8_t channel, std::optional target) const { + std::lock_guard guard(mutex_); + std::vector result; + const auto stateIt = states_.find(channel); + const auto presenceIt = presence_.find(channel); + const auto include = [&](uint8_t address) { + const DaliGatewayPresence presence = + presenceIt == presence_.end() ? DaliGatewayPresence::unknown : presenceIt->second[address]; + if (!target.has_value()) return presence == DaliGatewayPresence::online; + if (target->kind == DaliGatewayTargetKind::shortAddress) { + return target->value == address && presence != DaliGatewayPresence::offline; + } + if (target->kind == DaliGatewayTargetKind::broadcast) { + return presence == DaliGatewayPresence::online; + } + if (target->kind == DaliGatewayTargetKind::group && target->value < 16 && + stateIt != states_.end()) { + const auto& state = stateIt->second[address]; + return presence == DaliGatewayPresence::online && state.groupMaskKnown && + (state.groupMask & static_cast(1U << target->value)) != 0; + } + return false; + }; + for (uint8_t address = 0; address < 64; ++address) { + if (include(address)) result.push_back(address); + } + return result; +} + +bool DaliGatewayCache::clearChannelFlagsIfMatched(uint8_t channel, + const DaliGatewayChannelFlags& expected) { + std::lock_guard guard(mutex_); + auto it = flags_.find(channel); + if (it == flags_.end() || !SameFlags(it->second, expected)) return false; + it->second = {}; + return true; +} + +void DaliGatewayCache::markGroupUpdateNeeded(uint8_t channel, bool needed) { + std::lock_guard guard(mutex_); + flags_[channel].needUpdateGroup = needed; +} + +void DaliGatewayCache::markSceneUpdateNeeded(uint8_t channel, bool needed) { + std::lock_guard guard(mutex_); + flags_[channel].needUpdateScene = needed; +} + +void DaliGatewayCache::markSettingsUpdateNeeded(uint8_t channel, bool needed) { + std::lock_guard guard(mutex_); + flags_[channel].needUpdateSettings = needed; +} diff --git a/src/dali_protocol.cpp b/src/dali_protocol.cpp new file mode 100644 index 0000000..6281d9f --- /dev/null +++ b/src/dali_protocol.cpp @@ -0,0 +1,25 @@ +#include "dali_gateway.hpp" + +namespace { + +constexpr uint8_t kRawBroadcastArc = 0xFE; +constexpr uint8_t kRawBroadcastCommand = 0xFF; +constexpr uint8_t kRawGroupMin = 0x80; +constexpr uint8_t kRawGroupMax = 0x9F; + +} // namespace + +std::optional DaliGatewayCache::decodeTarget(uint8_t rawAddress) { + if (rawAddress < 0x80) { + return DaliGatewayTarget{DaliGatewayTargetKind::shortAddress, + static_cast(rawAddress >> 1)}; + } + if (rawAddress >= kRawGroupMin && rawAddress <= kRawGroupMax) { + return DaliGatewayTarget{DaliGatewayTargetKind::group, + static_cast((rawAddress - kRawGroupMin) >> 1)}; + } + if (rawAddress == kRawBroadcastArc || rawAddress == kRawBroadcastCommand) { + return DaliGatewayTarget{DaliGatewayTargetKind::broadcast, 0}; + } + return std::nullopt; +} diff --git a/tests/dali_gateway_test.cpp b/tests/dali_gateway_test.cpp new file mode 100644 index 0000000..afd30d5 --- /dev/null +++ b/tests/dali_gateway_test.cpp @@ -0,0 +1,119 @@ +#include "dali_gateway.hpp" + +#include +#include +#include +#include + +int main() { + DaliGatewayCache cache; + std::map, std::string> persisted; + int commits = 0; + cache.setPersistenceCallbacks({ + [&](uint8_t channel, uint8_t address) -> std::optional { + const auto it = persisted.find({channel, address}); + return it == persisted.end() ? std::nullopt + : std::optional(it->second); + }, + [&](uint8_t channel, uint8_t address, const std::optional& payload) { + if (payload.has_value()) { + persisted[{channel, address}] = *payload; + } else { + persisted.erase({channel, address}); + } + return true; + }, + [&]() { + ++commits; + return true; + }, + }); + + int status_updates = 0; + DaliGatewayStatusUpdate last_update; + cache.setStatusUpdateCallback([&](const DaliGatewayStatusUpdate& update) { + ++status_updates; + last_update = update; + }); + + assert(cache.setGroupMask(7, 4, 0x0001)); + assert(cache.mirrorForwardFrame(7, 0x80, 42)); + assert(status_updates == 1); + assert(last_update.channel == 7); + assert(last_update.target.kind == DaliGatewayTargetKind::group); + assert(last_update.target.value == 0); + assert(last_update.status.actualLevel == 42); + assert(last_update.affectedShortAddresses == std::vector{4}); + assert(cache.groupStatus(7, 0).actualLevel == 42); + assert(cache.addressState(7, 4).status.actualLevel == 42); + + // The command-state cache tracks DTR-dependent settings and applies a + // broadcast command to every state known by the adapter. + cache.mirrorForwardFrame(7, 0xA3, 12); + assert(cache.mirrorForwardFrame(7, 0x09, 0x2B)); + assert(cache.addressState(7, 4).settings.minLevel == 12); + assert(cache.mirrorForwardFrame(7, 0xFF, 0x06)); + assert(cache.addressState(7, 4).status.actualLevel == 12); + + assert(cache.observeForwardFrame(7, 0x09, 0x60, DaliGatewayFrameOrigin::outsideBus)); + assert(cache.pendingChannelFlags(7).needUpdateGroup); + assert(cache.flush()); + assert(commits == 1); + assert(!persisted.empty()); + + DaliGatewayCache restored; + restored.setPersistenceCallbacks({ + [&](uint8_t channel, uint8_t address) -> std::optional { + const auto it = persisted.find({channel, address}); + return it == persisted.end() ? std::nullopt + : std::optional(it->second); + }, + {}, + {}, + }); + assert(restored.preloadChannel(7)); + const auto restored_state = restored.addressState(7, 4); + assert(restored_state.groupMaskKnown); + assert(restored_state.groupMask == 0x0001); + assert(restored_state.settings.minLevel == 12); + assert(restored_state.status.stale); + + DaliApplicationControllerConfig config; + config.shortAddress = 3; + config.randomAddress = 0x123456; + DaliApplicationController controller(config); + + const auto capabilities = controller.handleForwardFrame({0x07, 0xFE, 0x46}); + assert(capabilities.backwardFrame == 0x01); + const auto status = controller.handleForwardFrame({0x07, 0xFE, 0x30}); + assert(status.backwardFrame == 0x28); + + // Commission short address 5 by selecting the controller's random address. + controller.handleForwardFrame({0xC1, 0x01, 0xFF}, true); + controller.handleForwardFrame({0xC1, 0x05, 0x12}); + controller.handleForwardFrame({0xC1, 0x06, 0x34}); + controller.handleForwardFrame({0xC1, 0x07, 0x56}); + const auto compared = controller.handleForwardFrame({0xC1, 0x03, 0x00}); + assert(compared.backwardFrame == 0xFF); + const auto programmed = controller.handleForwardFrame({0xC1, 0x08, 0x0B}); + assert(programmed.shortAddressChanged); + assert(programmed.shortAddress == 5); + const auto verified = controller.handleForwardFrame({0xC1, 0x09, 0x0B}); + assert(verified.backwardFrame == 0xFF); + controller.handleForwardFrame({0xC1, 0x30, 0x5A}); + const auto dtr0 = controller.handleForwardFrame({0x0B, 0xFE, 0x36}); + assert(dtr0.backwardFrame == 0x5A); + + const auto before_repeat = controller.handleForwardFrame({0x0B, 0xFE, 0x00}); + assert(!before_repeat.identifyRequested); + const auto identity = controller.handleForwardFrame({0x0B, 0xFE, 0x00}, true); + assert(identity.identifyRequested); + + DaliApplicationControllerConfig unaddressed_config; + unaddressed_config.shortAddress.reset(); + DaliApplicationController unaddressed(unaddressed_config); + const auto missing_short_address = unaddressed.handleForwardFrame({0xFF, 0xFE, 0x33}); + assert(missing_short_address.backwardFrame == 0xFF); + const auto unaddressed_status = unaddressed.handleForwardFrame({0xFF, 0xFE, 0x30}); + assert(unaddressed_status.backwardFrame == 0x2C); +}