From 02faa08df1f8ea92936dcccdbb680c3dbea8da56 Mon Sep 17 00:00:00 2001 From: babayaga Date: Sun, 18 Jan 2026 11:46:39 +0100 Subject: [PATCH] Add AmperageBudgetManager, InfluxDB, ModbusMirror; expose version --- src/components/AmperageBudgetManager.cpp | 1303 ++++++++++++++++++++++ src/components/AmperageBudgetManager.h | 202 ++++ src/components/InfluxDB.cpp | 90 ++ src/components/InfluxDB.h | 31 + src/components/ModbusMirror.cpp | 160 +++ src/components/ModbusMirror.h | 68 ++ src/components/RestServer.cpp | 12 +- 7 files changed, 1865 insertions(+), 1 deletion(-) create mode 100644 src/components/AmperageBudgetManager.cpp create mode 100644 src/components/AmperageBudgetManager.h create mode 100644 src/components/InfluxDB.cpp create mode 100644 src/components/InfluxDB.h create mode 100644 src/components/ModbusMirror.cpp create mode 100644 src/components/ModbusMirror.h diff --git a/src/components/AmperageBudgetManager.cpp b/src/components/AmperageBudgetManager.cpp new file mode 100644 index 00000000..703e615c --- /dev/null +++ b/src/components/AmperageBudgetManager.cpp @@ -0,0 +1,1303 @@ +#include "config.h" + +#ifdef ENABLE_AMPERAGE_BUDGET_MANAGER +#include +#include +#include +#include +#include +#include + +#include "components/AmperageBudgetManager.h" + +using namespace JsonUtils; + +#define SEQ_LOOP_INTERVAL_MS 50 + +short AmperageBudgetManager::reset() +{ + _stopAllDevices(); + _currentIndex = m_startIndex.getValue(); + _heatupPhaseComplete = false; + for (uint8_t i = 0; i < MAX_MANAGED_DEVICES; ++i) + { + _deviceInHeatup[i] = false; + } + return E_OK; +} + +void AmperageBudgetManager::_stopAllDevices() +{ + if (_stoppingIndex == -1) + { + _stoppingIndex = 0; + _lastStopTimestamp = 0; // Force immediate start + } +} + +AmperageBudgetManager::AmperageBudgetManager(Component *owner, uint16_t baseAddress) + : NetworkComponent(baseAddress, "AmperageBudgetManager", COMPONENT_KEY_AMPERAGE_BUDGET_MANAGER, Component::COMPONENT_DEFAULT, owner), + _numDevices(0), + _currentIndex(0), + m_minHeatingDurationS(this, this->id, "MinHeatingDurationS"), + m_maxHeatingDurationS(this, this->id, "MaxHeatingDurationS"), + m_maxHeatingDurationOscillatingS(this, this->id, "MaxHeatingDurationOscillatingS"), + m_maxSimultaneousHeating(this, this->id, "MaxSimultaneousHeating"), + m_windowOffset(this, this->id, "WindowOffset"), + m_mode(this, this->id, "Mode(0:Cycle All,1:Cycle SP,2:Any SP,3:Most Urgent (Recommended))"), + m_postHeatupMode(this, this->id, "PostHeatupMode"), + m_startIndex(this, this->id, "StartIndex"), + m_endIndex(this, this->id, "EndIndex"), + m_opFlags(this, this->id, "OpFlags"), + _lastLoopTime(0), + _initialMode(E_AM_CYCLE_SP_MOST_URGENT), + _heatupPhaseComplete(false), + _canUseCallback(nullptr), + _stoppingIndex(-1), + _lastStopTimestamp(0) +{ + pFlags = E_PersistenceFlags::E_PF_ENABLED; + for (uint8_t i = 0; i < MAX_MANAGED_DEVICES; ++i) + { + _devices[i] = nullptr; + _deviceStartTimes[i] = 0; + _deviceHeating[i] = false; + _deviceInHeatup[i] = false; + } +} + +bool AmperageBudgetManager::addManagedDevice(OmronE5 *device) +{ + if (_numDevices >= MAX_MANAGED_DEVICES) + { + L_ERROR(F("[%s] Cannot add more devices, manager full (%d)."), _name.c_str(), MAX_MANAGED_DEVICES); + return false; + } + if (device == nullptr) + { + L_ERROR(F("[%s] Cannot add null device pointer."), _name.c_str()); + return false; + } + + for (uint8_t i = 0; i < _numDevices; ++i) + { + if (_devices[i] == device) + { + Log.warningln(F("[%s] Device already added (Index %d). Ignoring."), _name.c_str(), i); + return true; + } + } + + _devices[_numDevices++] = device; + Log.traceln(F("[%s] Added device %d."), _name.c_str(), _numDevices - 1); + return true; +} + +short AmperageBudgetManager::setup() +{ + NetworkComponent::setup(); + + const uint16_t baseAddr = mb_tcp_base_address(); + + m_minHeatingDurationS.initNotify(MIN_HEATING_DURATION_S, 1, NetworkValue_ThresholdMode::DIFFERENCE); + m_minHeatingDurationS.initModbus(baseAddr + REG_OFFSET_MIN_TIME, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, "MinTime", this->name.c_str()); + registerBlock(m_minHeatingDurationS.getRegisterInfo()); + + m_maxHeatingDurationS.initNotify(MAX_HEATING_DURATION_S, 1, NetworkValue_ThresholdMode::DIFFERENCE); + m_maxHeatingDurationS.initModbus(baseAddr + REG_OFFSET_MAX_TIME, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, "MaxTime", this->name.c_str()); + registerBlock(m_maxHeatingDurationS.getRegisterInfo()); + + m_maxHeatingDurationOscillatingS.initNotify(DEFAULT_MAX_HEATING_OSCILLATING_S, 1, NetworkValue_ThresholdMode::DIFFERENCE); + m_maxHeatingDurationOscillatingS.initModbus(baseAddr + REG_OFFSET_MAX_TIME_OSCILLATING, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, "MaxTimeOscillating", this->name.c_str()); + registerBlock(m_maxHeatingDurationOscillatingS.getRegisterInfo()); + + m_maxSimultaneousHeating.initNotify(DEFAULT_MAX_SIMULTANEOUS_HEATING, 1, NetworkValue_ThresholdMode::DIFFERENCE); + m_maxSimultaneousHeating.initModbus(baseAddr + REG_OFFSET_MAX_SIM, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, "MaxSim", this->name.c_str()); + registerBlock(m_maxSimultaneousHeating.getRegisterInfo()); + + m_windowOffset.initNotify(DEFAULT_WINDOW_OFFSET, 1, NetworkValue_ThresholdMode::DIFFERENCE); + m_windowOffset.initModbus(baseAddr + REG_OFFSET_OFFSET, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, "Offset", this->name.c_str()); + registerBlock(m_windowOffset.getRegisterInfo()); + + m_mode.initNotify(E_AM_CYCLE_ALL, (E_AMPERAGE_MODE)1, NetworkValue_ThresholdMode::DIFFERENCE); + m_mode.initModbus(baseAddr + REG_OFFSET_MODE, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, "Mode(0:Cycle All,1:Cycle SP,2:Any SP,3:Most Urgent)", this->name.c_str()); + registerBlock(m_mode.getRegisterInfo()); + + m_postHeatupMode.initNotify(E_AM_CYCLE_ALL, (E_AMPERAGE_MODE)1, NetworkValue_ThresholdMode::DIFFERENCE); + m_postHeatupMode.initModbus(baseAddr + REG_OFFSET_POST_HEATUP_MODE, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, "PostHeatupMode", this->name.c_str()); + registerBlock(m_postHeatupMode.getRegisterInfo()); + + m_startIndex.initNotify(DEFAULT_START_INDEX, 1, NetworkValue_ThresholdMode::DIFFERENCE); + m_startIndex.initModbus(baseAddr + REG_OFFSET_START_INDEX, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, "StartIndex", this->name.c_str()); + registerBlock(m_startIndex.getRegisterInfo()); + + m_endIndex.initNotify(DEFAULT_END_INDEX, 1, NetworkValue_ThresholdMode::DIFFERENCE); + m_endIndex.initModbus(baseAddr + REG_OFFSET_END_INDEX, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, "EndIndex", this->name.c_str()); + registerBlock(m_endIndex.getRegisterInfo()); + + m_opFlags.initNotify(E_SQ_NONE, 1, NetworkValue_ThresholdMode::DIFFERENCE); + m_opFlags.initModbus(baseAddr + REG_OFFSET_OP_FLAGS, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, "OpFlags", this->name.c_str()); + registerBlock(m_opFlags.getRegisterInfo()); + + MB_Registers infoReg(baseAddr + REG_OFFSET_INFO, 1, FN_READ_HOLD_REGISTER, MB_ACCESS_READ_ONLY, this->id, this->slaveId, "Info", this->name.c_str()); + registerBlock(infoReg); + + _stopAllDevices(); // Ensure all devices are stopped during setup + _activeDevices.reserve(MAX_MANAGED_DEVICES); + + _currentIndex = m_startIndex.getValue(); // Initialize current index to start index + // Try to load configuration + if (!load("/amperage_budget.json")) + { + L_WARN(F("[%s] Using default configuration"), _name.c_str()); + } + disable(); + return E_OK; +} + +bool AmperageBudgetManager::_checkHeatup(OmronE5 *device) +{ + if (!device->enabled()) + { + return false; + } + if (device->hasError()) + { + return false; + } + if (_canUseCallback && !_canUseCallback(this->owner, device)) + { + return false; + } + return device->isHeatup(); +} + +void AmperageBudgetManager::_loopCycleAll() +{ + uint32_t now_s = now / 1000; + auto canStartHeating = [&](uint8_t deviceIndex) -> bool + { + if (_numDevices == 0) + return false; + if (deviceIndex < m_startIndex.getValue() || deviceIndex > m_endIndex.getValue()) + return false; // Device is outside the cycle range + + // Calculate the number of active devices in the cycle range + uint8_t numActiveDevices = 0; + for (uint8_t i = m_startIndex.getValue(); i <= m_endIndex.getValue(); ++i) + { + if (i < _numDevices && _devices[i] && _devices[i]->enabled()) + { // Ensure we don't go out of bounds of actual devices + numActiveDevices++; + } + } + if (numActiveDevices == 0) + return false; + + // Calculate window based on current index and max simultaneous heating + // The window is within the [_startIndex, _endIndex] range + uint8_t windowStart = _currentIndex; + // Ensure maxSimultaneousHeating does not exceed the number of devices in the current range + uint8_t effectiveMaxSimultaneous = m_maxSimultaneousHeating.getValue() > numActiveDevices ? numActiveDevices : m_maxSimultaneousHeating.getValue(); + + uint8_t devicesInWindow = 0; + uint8_t tempIndex = windowStart; + std::vector windowDeviceIndices; + + while (devicesInWindow < effectiveMaxSimultaneous) + { + if (tempIndex < _numDevices && _devices[tempIndex] && _devices[tempIndex]->enabled()) + { + windowDeviceIndices.push_back(tempIndex); + devicesInWindow++; + } + + tempIndex++; + if (tempIndex > m_endIndex.getValue()) + { + tempIndex = m_startIndex.getValue(); // Wrap around to the start index + } + if (tempIndex == windowStart) + { // Full circle + break; + } + } + + // Check if deviceIndex is in the calculated windowDeviceIndices + for (uint8_t idx : windowDeviceIndices) + { + if (deviceIndex == idx) + { + return true; + } + } + return false; + }; + + auto advanceCurrentIndex = [&]() + { + uint8_t oldIndex = _currentIndex; + uint8_t numActiveDevicesInRange = 0; + for (uint8_t i = m_startIndex.getValue(); i <= m_endIndex.getValue(); ++i) + { + if (i < _numDevices && _devices[i] && _devices[i]->enabled()) + numActiveDevicesInRange++; + } + + if (numActiveDevicesInRange > 0) + { + uint8_t currentOffset = 0; + uint8_t nextIndex = _currentIndex; + do + { + nextIndex++; + if (nextIndex > m_endIndex.getValue()) + { + nextIndex = m_startIndex.getValue(); // Wrap around to the start index + } + if (nextIndex < _numDevices && _devices[nextIndex] && _devices[nextIndex]->enabled()) + { + currentOffset++; + } + } while (currentOffset < m_windowOffset.getValue() && nextIndex != _currentIndex); + _currentIndex = nextIndex; + } + }; + + auto updateDevice = [&](uint8_t deviceIndex) -> bool + { + OmronE5 *device = _devices[deviceIndex]; + if (deviceIndex >= _numDevices || device == nullptr) + return false; + + if (deviceIndex < m_startIndex.getValue() || deviceIndex > m_endIndex.getValue()) + { + return false; + } + + if (_deviceHeating[deviceIndex]) + { + // If the device is no longer allowed to be used (e.g., its profile was stopped), stop it immediately. + if (!_checkHeatup(device)) + { + device->stop(); + _deviceHeating[deviceIndex] = false; + if (deviceIndex == _currentIndex) + { + advanceCurrentIndex(); + } + return true; // Action taken + } + + bool isPastHeatup = !_deviceInHeatup[deviceIndex]; + uint32_t maxDuration = isPastHeatup ? m_maxHeatingDurationOscillatingS.getValue() : m_maxHeatingDurationS.getValue(); + + if (isPastHeatup && !device->isHeating()) + { + device->stop(); + _deviceHeating[deviceIndex] = false; + if (deviceIndex == _currentIndex) + { + advanceCurrentIndex(); + } + return true; // Action taken + } + else if (now_s - _deviceStartTimes[deviceIndex] >= maxDuration) + { + device->stop(); + _deviceHeating[deviceIndex] = false; + if (deviceIndex == _currentIndex) + { + advanceCurrentIndex(); + } + return true; // Action taken + } + else if (now_s - _deviceStartTimes[deviceIndex] < m_minHeatingDurationS.getValue()) + { + return false; + } + } + else + { + if (canStartHeating(deviceIndex) && _deviceInHeatup[deviceIndex]) + { + device->run(); + _deviceStartTimes[deviceIndex] = now_s; + _deviceHeating[deviceIndex] = true; + return true; // Action taken + } + } + return false; + }; + + // If the device at the current index isn't heating and doesn't need to, advance the index. + if (_currentIndex < _numDevices && _devices[_currentIndex] != nullptr && !_deviceHeating[_currentIndex]) + { + if (!_checkHeatup(_devices[_currentIndex])) + { + advanceCurrentIndex(); + } + } + + for (uint8_t i = 0; i < _numDevices; ++i) + { + if (updateDevice(i)) + { + return; // Limit to one action per cycle + } + } +} + +void AmperageBudgetManager::_loopCycleSp() +{ + auto getWindowDeviceIndices = [&]() -> std::vector + { + std::vector indices; + uint8_t numActiveDevicesInRange = 0; + for (uint8_t i = m_startIndex.getValue(); i <= m_endIndex.getValue(); ++i) + { + if (i < _numDevices && _devices[i] && _devices[i]->enabled()) + { + numActiveDevicesInRange++; + } + } + + if (numActiveDevicesInRange == 0) + return indices; + + uint8_t effectiveMaxSimultaneous = m_maxSimultaneousHeating.getValue() > numActiveDevicesInRange ? numActiveDevicesInRange : m_maxSimultaneousHeating.getValue(); + uint8_t devicesInWindow = 0; + uint8_t tempIndex = _currentIndex; + + while (devicesInWindow < effectiveMaxSimultaneous) + { + if (tempIndex < _numDevices && _devices[tempIndex] && _devices[tempIndex]->enabled()) + { + indices.push_back(tempIndex); + devicesInWindow++; + } + + tempIndex++; + if (tempIndex > m_endIndex.getValue()) + { + tempIndex = m_startIndex.getValue(); // Wrap around + } + if (tempIndex == _currentIndex) + { // Full circle + break; + } + } + return indices; + }; + + auto advanceCurrentIndex = [&]() + { + uint8_t numActiveDevicesInRange = 0; + for (uint8_t i = m_startIndex.getValue(); i <= m_endIndex.getValue(); ++i) + { + if (i < _numDevices && _devices[i] && _devices[i]->enabled()) + numActiveDevicesInRange++; + } + + if (numActiveDevicesInRange > 0) + { + uint8_t currentOffset = 0; + uint8_t nextIndex = _currentIndex; + do + { + nextIndex++; + if (nextIndex > m_endIndex.getValue()) + { + nextIndex = m_startIndex.getValue(); // Wrap around to the start index + } + if (nextIndex < _numDevices && _devices[nextIndex] && _devices[nextIndex]->enabled()) + { + currentOffset++; + } + } while (currentOffset < m_windowOffset.getValue() && nextIndex != _currentIndex); + _currentIndex = nextIndex; + } + }; + + std::vector windowIndices = getWindowDeviceIndices(); + + bool allInWindowAreDone = true; + if (!windowIndices.empty()) + { + for (uint8_t idx : windowIndices) + { + if (_checkHeatup(_devices[idx])) + { + allInWindowAreDone = false; + break; + } + } + } + + if (allInWindowAreDone) + { + advanceCurrentIndex(); + windowIndices = getWindowDeviceIndices(); + } + + for (uint8_t i = 0; i < _numDevices; i++) + { + if (_devices[i] == nullptr) + continue; + + if (!_devices[i]->enabled()) + { + _devices[i]->stop(); + _deviceHeating[i] = false; + continue; + } + + bool isInWindow = false; + for (uint8_t windowIdx : windowIndices) + { + if (i == windowIdx) + { + isInWindow = true; + break; + } + } + + if (isInWindow && _checkHeatup(_devices[i])) + { + _devices[i]->run(); + _deviceHeating[i] = true; + return; // One action per cycle + } + else + { + _devices[i]->stop(); + _deviceHeating[i] = false; + // Note: Stop also counts as an action? + // If we are strictly sequential, yes. + // But stops are often "safe defaults". + // Given "break may flip", turning ON is the danger. + // But user said "updates ( stop / run )". + // So we return here too. + return; + } + } +} + +void AmperageBudgetManager::_loopCycleSpAny() +{ + std::vector needsHeatingIndices; + // Find all enabled devices that need heating within the specified range + for (uint8_t i = m_startIndex.getValue(); i <= m_endIndex.getValue(); ++i) + { + if (i < _numDevices && _devices[i] && _devices[i]->enabled() && _checkHeatup(_devices[i])) + { + needsHeatingIndices.push_back(i); + } + } + + // Now, iterate through all devices and decide if they should be on or off. + for (uint8_t i = 0; i < _numDevices; i++) + { + if (!_devices[i]) + continue; + + bool shouldBeHeating = false; + // Check if this device 'i' is one of the ones that needs heating + for (size_t j = 0; j < needsHeatingIndices.size(); ++j) + { + // And if its position in the 'needs list' is within our budget + if (needsHeatingIndices[j] == i && j < m_maxSimultaneousHeating.getValue()) + { + shouldBeHeating = true; + break; + } + } + + if (shouldBeHeating) + { + if (!_deviceHeating[i]) + { + _devices[i]->run(); + _deviceHeating[i] = true; + return; // One action per cycle + } + } + else + { + if (_deviceHeating[i]) + { + _devices[i]->stop(); + _deviceHeating[i] = false; + return; // One action per cycle + } + } + } +} + +void AmperageBudgetManager::_loopCycleSpMostUrgent() +{ + struct DeviceUrgency + { + uint8_t index; + int32_t urgency; // Difference between SP and PV + + bool operator<(const DeviceUrgency &other) const + { + return urgency > other.urgency; // Higher urgency (bigger diff) comes first + } + }; + + std::vector urgentDevices; + for (uint8_t i = m_startIndex.getValue(); i <= m_endIndex.getValue(); ++i) + { + if (i < _numDevices && _devices[i] && _devices[i]->enabled() && _checkHeatup(_devices[i])) + { + uint16_t pv, sp; + if (_devices[i]->getPV(pv) && _devices[i]->getSP(sp)) + { + if (sp > pv) + { + urgentDevices.push_back({i, (int32_t)sp - pv}); + } + } + } + } + + std::sort(urgentDevices.begin(), urgentDevices.end()); + + std::vector devicesToHeat; + for (size_t i = 0; i < urgentDevices.size() && i < m_maxSimultaneousHeating.getValue(); ++i) + { + devicesToHeat.push_back(urgentDevices[i].index); + } + + uint32_t now_s = now / 1000; + + for (uint8_t i = 0; i < _numDevices; i++) + { + if (_devices[i] == nullptr || !_devices[i]->enabled()) + { + if (_deviceHeating[i]) + { + _devices[i]->stop(); + _deviceHeating[i] = false; + } + continue; + } + + bool shouldHeat = false; + for (uint8_t heatIndex : devicesToHeat) + { + if (i == heatIndex) + { + shouldHeat = true; + break; + } + } + + if (_deviceHeating[i]) + { + bool isPastHeatup = !_deviceInHeatup[i]; + uint32_t maxDuration = isPastHeatup ? m_maxHeatingDurationOscillatingS.getValue() : m_maxHeatingDurationS.getValue(); + + if (!shouldHeat || (now_s - _deviceStartTimes[i] >= maxDuration)) + { + _devices[i]->stop(); + _deviceHeating[i] = false; + return; // One action per cycle + } + } + else + { + if (shouldHeat) + { + _devices[i]->run(); + _deviceStartTimes[i] = now_s; + _deviceHeating[i] = true; + return; // One action per cycle + } + } + } +} + +void AmperageBudgetManager::onCycleStart(const std::vector &activeDevices) +{ + for (uint8_t i = 0; i < _numDevices; ++i) + { + if (_devices[i] == nullptr) + continue; + + // Check if the current device is in the list of devices that should be active. + bool found = (std::find(activeDevices.begin(), activeDevices.end(), _devices[i]) != activeDevices.end()); + + // If it's not in the list, it shouldn't be heating. Stop it for safety. + if (!found) + { + _devices[i]->stop(); + _deviceHeating[i] = false; + } + } +} + +void AmperageBudgetManager::onCycleEnd(const std::vector &activeDevices) +{ + for (const auto &device : activeDevices) + { + if (device) + { + // L_INFO(F(" - Device Active: ID %d"), device->slaveId); + } + } +} + +short AmperageBudgetManager::loop() +{ + Component::loop(); + + // Handle non-blocking sequential device stopping + if (_stoppingIndex >= 0) + { + uint32_t now_ms = millis(); + if (now_ms - _lastStopTimestamp >= STOP_ALL_DEVICES_WAIT_MS) + { + if (_stoppingIndex < _numDevices) + { + if (_deviceHeating[_stoppingIndex] && _devices[_stoppingIndex] != nullptr) + { + _devices[_stoppingIndex]->stop(); + _deviceHeating[_stoppingIndex] = false; + } + _stoppingIndex++; + _lastStopTimestamp = now_ms; + } + else + { + _stoppingIndex = -1; // Sequence complete + } + } + return 0; // Block other logic while stopping + } + + if (millis() - _lastLoopTime < SEQ_LOOP_INTERVAL_MS) + { + return 0; + } + _lastLoopTime = millis(); + _checkAllDevicesForHeatupCompletion(); + + _activeDevices.clear(); + for (uint8_t i = 0; i < _numDevices; ++i) + { + if (_deviceHeating[i]) + { + _activeDevices.push_back(_devices[i]); + } + } + onCycleStart(_activeDevices); + + if (!enabled() || _numDevices == 0) + { + onCycleEnd(_activeDevices); + return 0; + } + + switch (m_mode.getValue()) + { + case E_AM_CYCLE_ALL: + _loopCycleAll(); + break; + case E_AM_CYCLE_SP: + _loopCycleSp(); + break; + case E_AM_CYCLE_SP_ANY: + _loopCycleSpAny(); + break; + case E_AM_CYCLE_SP_MOST_URGENT: + _loopCycleSpMostUrgent(); + break; + } + + _activeDevices.clear(); + for (uint8_t i = 0; i < _numDevices; ++i) + { + if (_deviceHeating[i]) + { + _activeDevices.push_back(_devices[i]); + } + } + onCycleEnd(_activeDevices); + + return 0; +} + +short AmperageBudgetManager::info(short val0, short val1) +{ + L_INFO(F("[%s] Devices: %d/%d, Current Index: %d\n"), + _name.c_str(), _numDevices, MAX_MANAGED_DEVICES, _currentIndex); + + L_INFO(F(" Min Time: %lu s, Max Time: %lu s, Max Oscillating Time: %lu s\n"), + m_minHeatingDurationS.getValue(), m_maxHeatingDurationS.getValue(), m_maxHeatingDurationOscillatingS.getValue()); + L_INFO(F(" Max Simultaneous: %d, Window Offset: %d\n"), + m_maxSimultaneousHeating.getValue(), m_windowOffset.getValue()); + L_INFO(F(" Start Index: %d, End Index: %d\n"), + m_startIndex.getValue(), m_endIndex.getValue()); + L_INFO(F(" OpFlags: %d\n"), m_opFlags.getValue()); + + // Show the current window + uint8_t windowEnd = (_currentIndex + m_maxSimultaneousHeating.getValue() - 1); + if (_numDevices > 0) + { // Prevent division by zero or incorrect modulo with 0 devices + if (m_endIndex.getValue() < m_startIndex.getValue() || _numDevices <= m_startIndex.getValue()) + { // Handle invalid or empty range + windowEnd = _currentIndex; // Default to current index if range is bad + } + else + { + // Calculate the effective number of devices in the custom range + uint8_t devicesInRangeCount = 0; + for (uint8_t i = m_startIndex.getValue(); i <= m_endIndex.getValue(); ++i) + { + if (i < _numDevices) + devicesInRangeCount++; + } + + if (devicesInRangeCount > 0) + { + // Adjust windowEnd based on the custom range [_startIndex, _endIndex] + // and wrap around within this range. + uint8_t currentPosInRange = 0; + uint8_t tempIdx = m_startIndex.getValue(); + while (tempIdx != _currentIndex && tempIdx <= m_endIndex.getValue()) + { + if (tempIdx < _numDevices) + currentPosInRange++; + tempIdx++; + if (tempIdx > m_endIndex.getValue() && _currentIndex != m_startIndex.getValue()) + tempIdx = m_startIndex.getValue(); // Wrap + if (tempIdx == m_startIndex.getValue() && _currentIndex == m_startIndex.getValue()) + break; // Optimization for CI == SI + } + + uint8_t effectiveMaxSim = m_maxSimultaneousHeating.getValue() > devicesInRangeCount ? devicesInRangeCount : m_maxSimultaneousHeating.getValue(); + windowEnd = m_startIndex.getValue() + (currentPosInRange + effectiveMaxSim - 1) % devicesInRangeCount; + + // Ensure windowEnd does not exceed _endIndex by wrapping if necessary + // This calculation seems complex and might need further refinement for wrapping + // For now, let's try to keep it simple and show the logical end without complex wrapping visual + // The _canStartHeating function correctly determines who can heat. + // This is more for display. + uint8_t displayWindowEnd = _currentIndex; + uint8_t count = 0; + uint8_t temp_idx = _currentIndex; + while (count < m_maxSimultaneousHeating.getValue() && count < devicesInRangeCount) + { + displayWindowEnd = temp_idx; + count++; + temp_idx++; + if (temp_idx > m_endIndex.getValue()) + temp_idx = m_startIndex.getValue(); + if (temp_idx == _currentIndex && count < m_maxSimultaneousHeating.getValue()) + break; // full loop + } + Log.notice(F(" Active Window: Start %d, Logical End %d (MaxSim: %d, Range: %d-%d)"), + _currentIndex, displayWindowEnd, m_maxSimultaneousHeating.getValue(), m_startIndex.getValue(), m_endIndex.getValue()); + } + else + { + Log.notice(F(" Active Window: No devices in range %d-%d"), m_startIndex.getValue(), m_endIndex.getValue()); + } + } + } + else + { + Log.notice(F(" Active Window: No devices managed.\n")); + } + + for (uint8_t i = 0; i < _numDevices; ++i) + { + if (_deviceHeating[i]) + { + uint32_t elapsed_s = (millis() / 1000) - _deviceStartTimes[i]; + Log.notice(F(" Device %d: Heating for %lus\n"), i, elapsed_s); + } + else + { + Log.notice(F(" Device %d: Not heating\n"), i); + } + } + + return 0; +} + +short AmperageBudgetManager::mb_tcp_write(MB_Registers *reg, short value) +{ + short result = NetworkComponent::mb_tcp_write(reg, value); + if (result != E_NOT_IMPLEMENTED) + return result; + + uint16_t address = reg->startAddress; + bool changed = false; + + if (address == (_baseAddress + REG_OFFSET_MIN_TIME)) + { + if (_validateMinTime(value)) + { + if (m_minHeatingDurationS.getValue() != (uint16_t)value) + { + m_minHeatingDurationS.update(value); + reset(); + changed = true; + } + } + else + { + return E_INVALID_PARAMETER; + } + } + else if (address == (_baseAddress + REG_OFFSET_MAX_TIME)) + { + if (_validateMaxTime(value)) + { + if (m_maxHeatingDurationS.getValue() != (uint16_t)value) + { + m_maxHeatingDurationS.update(value); + reset(); + changed = true; + } + } + else + { + return E_INVALID_PARAMETER; + } + } + else if (address == (_baseAddress + REG_OFFSET_MAX_SIM)) + { + if (_validateMaxSim(value)) + { + if (m_maxSimultaneousHeating.getValue() != (uint8_t)value) + { + m_maxSimultaneousHeating.update(value); + reset(); + changed = true; + } + } + else + { + return E_INVALID_PARAMETER; + } + } + else if (address == (_baseAddress + REG_OFFSET_OFFSET)) + { + if (_validateOffset(value)) + { + if (m_windowOffset.getValue() != (uint8_t)value) + { + m_windowOffset.update(value); + reset(); + changed = true; + } + } + else + { + return E_INVALID_PARAMETER; + } + } + else if (address == (_baseAddress + REG_OFFSET_START_INDEX)) + { + if (_validateStartIndex(value)) + { + if (m_startIndex.getValue() != (uint8_t)value) + { + m_startIndex.update(value); + reset(); + changed = true; + } + } + else + { + return E_INVALID_PARAMETER; + } + } + else if (address == (_baseAddress + REG_OFFSET_END_INDEX)) + { + if (_validateEndIndex(value)) + { + if (m_endIndex.getValue() != (uint8_t)value) + { + m_endIndex.update(value); + reset(); + changed = true; + } + } + else + { + return E_INVALID_PARAMETER; + } + } + else if (address == (_baseAddress + REG_OFFSET_MODE)) + { + if (value >= E_AM_CYCLE_ALL && value <= E_AM_CYCLE_SP_MOST_URGENT) + { + if (m_mode.getValue() != (E_AMPERAGE_MODE)value) + { + m_mode.update((E_AMPERAGE_MODE)value); + reset(); + changed = true; + } + } + else + { + return E_INVALID_PARAMETER; + } + } + else if (address == (_baseAddress + REG_OFFSET_MAX_TIME_OSCILLATING)) + { + if (_validateMaxTimeOscillating(value)) + { + if (m_maxHeatingDurationOscillatingS.getValue() != (uint16_t)value) + { + m_maxHeatingDurationOscillatingS.update(value); + reset(); + changed = true; + } + } + else + { + return E_INVALID_PARAMETER; + } + } + else if (address == (_baseAddress + REG_OFFSET_POST_HEATUP_MODE)) + { + if (value >= E_AM_CYCLE_ALL && value <= E_AM_CYCLE_SP_MOST_URGENT) + { + if (m_postHeatupMode.getValue() != (E_AMPERAGE_MODE)value) + { + m_postHeatupMode.update((E_AMPERAGE_MODE)value); + // No reset needed for this one as per original code + changed = true; + } + } + else + { + return E_INVALID_PARAMETER; + } + } + else if (address == (_baseAddress + REG_OFFSET_OP_FLAGS)) + { + if (m_opFlags.getValue() != (uint16_t)value) + { + m_opFlags.update(value); + changed = true; + } + } + else + { + return E_INVALID_PARAMETER; + } + + if (changed) + { + save("/amperage_budget.json"); + } + + return E_OK; +} + +short AmperageBudgetManager::mb_tcp_read(MB_Registers *reg) +{ + short result = NetworkComponent::mb_tcp_read(reg); + if (result != E_NOT_IMPLEMENTED) + return result; + + uint16_t address = reg->startAddress; + + if (address == (_baseAddress + REG_OFFSET_INFO)) + { + // Return a bit-packed status: + // Bit 0: Enabled + // Bits 1-8: Heating status for each device (1 if heating, 0 if not) + // Bits 9-11: Number of devices + // Bits 12-14: Current index + // Bit 15: Reserved + uint16_t status = enabled() ? 1 : 0; + for (uint8_t i = 0; i < _numDevices && i < 8; ++i) + { + if (_deviceHeating[i]) + { + status |= (1 << (i + 1)); + } + } + status |= (_numDevices & 0x07) << 9; + status |= (_currentIndex & 0x07) << 12; + return status; + } + if (address == (_baseAddress + REG_OFFSET_MIN_TIME)) + { + return m_minHeatingDurationS.getValue(); + } + if (address == (_baseAddress + REG_OFFSET_MAX_TIME)) + { + return m_maxHeatingDurationS.getValue(); + } + if (address == (_baseAddress + REG_OFFSET_MAX_SIM)) + { + return m_maxSimultaneousHeating.getValue(); + } + if (address == (_baseAddress + REG_OFFSET_OFFSET)) + { + return m_windowOffset.getValue(); + } + if (address == (_baseAddress + REG_OFFSET_START_INDEX)) + { + return m_startIndex.getValue(); + } + if (address == (_baseAddress + REG_OFFSET_END_INDEX)) + { + return m_endIndex.getValue(); + } + if (address == (_baseAddress + REG_OFFSET_MODE)) + { + return m_mode.getValue(); + } + if (address == (_baseAddress + REG_OFFSET_MAX_TIME_OSCILLATING)) + { + return m_maxHeatingDurationOscillatingS.getValue(); + } + if (address == (_baseAddress + REG_OFFSET_POST_HEATUP_MODE)) + { + return m_postHeatupMode.getValue(); + } + if (address == (_baseAddress + REG_OFFSET_OP_FLAGS)) + { + return m_opFlags.getValue(); + } + return 0; +} + +short AmperageBudgetManager::serial_register(Bridge *bridge) +{ + Component::serial_register(bridge); + bridge->registerMemberFunction(id, this, C_STR("info"), (ComponentFnPtr)&AmperageBudgetManager::info); + return E_OK; +} + +void AmperageBudgetManager::notifyStateChange() +{ + Component::notifyStateChange(); + if (enabled()) + { + reset(); + } + else + { + _stopAllDevices(); + } +} + +void AmperageBudgetManager::toJson(JsonDocument &doc) const +{ + JsonObject obj = doc.to(); + + obj["minHeatingDurationS"] = m_minHeatingDurationS.getValue(); + obj["maxHeatingDurationS"] = m_maxHeatingDurationS.getValue(); + obj["maxHeatingDurationOscillatingS"] = m_maxHeatingDurationOscillatingS.getValue(); + obj["maxSimultaneousHeating"] = m_maxSimultaneousHeating.getValue(); + obj["windowOffset"] = m_windowOffset.getValue(); + obj["enabled"] = const_cast(this)->enabled(); + obj["startIndex"] = m_startIndex.getValue(); + obj["endIndex"] = m_endIndex.getValue(); + if (_heatupPhaseComplete) + { + obj["mode"] = _initialMode; + } + else + { + obj["mode"] = m_mode.getValue(); + } + obj["postHeatupMode"] = m_postHeatupMode.getValue(); + obj["opFlags"] = m_opFlags.getValue(); +} + +bool AmperageBudgetManager::fromJson(const JsonObject &json) +{ + if (json.isNull()) + { + Log.warningln(F("[%s] fromJson: Provided JSON object is null. Using defaults."), _name.c_str()); + return false; + } + uint32_t minHeatingDurationS = m_minHeatingDurationS.getValue(); + uint32_t maxHeatingDurationS = m_maxHeatingDurationS.getValue(); + uint32_t maxHeatingDurationOscillatingS = m_maxHeatingDurationOscillatingS.getValue(); + uint8_t maxSimultaneousHeating = m_maxSimultaneousHeating.getValue(); + uint8_t windowOffset = m_windowOffset.getValue(); + + JsonUtils::parseJsonFieldUint32(json, "minHeatingDurationS", minHeatingDurationS, "minHeatingDurationS", _name.c_str()); + m_minHeatingDurationS.applyUpdate(minHeatingDurationS); + JsonUtils::parseJsonFieldUint32(json, "maxHeatingDurationS", maxHeatingDurationS, "maxHeatingDurationS", _name.c_str()); + m_maxHeatingDurationS.applyUpdate(maxHeatingDurationS); + JsonUtils::parseJsonFieldUint32(json, "maxHeatingDurationOscillatingS", maxHeatingDurationOscillatingS, "maxHeatingDurationOscillatingS", _name.c_str()); + m_maxHeatingDurationOscillatingS.applyUpdate(maxHeatingDurationOscillatingS); + JsonUtils::parseJsonFieldUint8(json, "maxSimultaneousHeating", maxSimultaneousHeating, "maxSimultaneousHeating", _name.c_str()); + m_maxSimultaneousHeating.applyUpdate(maxSimultaneousHeating); + JsonUtils::parseJsonFieldUint8(json, "windowOffset", windowOffset, "windowOffset", _name.c_str()); + m_windowOffset.applyUpdate(windowOffset); + + uint8_t tempMode = m_mode.getValue(); + JsonUtils::parseJsonFieldUint8(json, "mode", tempMode, "mode", _name.c_str()); + if (tempMode >= E_AM_CYCLE_ALL && tempMode <= E_AM_CYCLE_SP_MOST_URGENT) + { + m_mode.applyUpdate((E_AMPERAGE_MODE)tempMode); + } + + uint32_t tempOpFlags32 = m_opFlags.getValue(); + JsonUtils::parseJsonFieldUint32(json, "opFlags", tempOpFlags32, "opFlags", _name.c_str()); + m_opFlags.applyUpdate((uint16_t)tempOpFlags32); + + uint8_t tempPostHeatupMode = m_postHeatupMode.getValue(); + JsonUtils::parseJsonFieldUint8(json, "postHeatupMode", tempPostHeatupMode, "postHeatupMode", _name.c_str()); + if (tempPostHeatupMode >= E_AM_CYCLE_ALL && tempPostHeatupMode <= E_AM_CYCLE_SP_MOST_URGENT) + { + m_postHeatupMode.applyUpdate((E_AMPERAGE_MODE)tempPostHeatupMode); + } + + uint8_t tempStartIndex = m_startIndex.getValue(); + uint8_t tempEndIndex = m_endIndex.getValue(); + JsonUtils::parseJsonFieldUint8(json, "startIndex", tempStartIndex, "startIndex", _name.c_str()); + JsonUtils::parseJsonFieldUint8(json, "endIndex", tempEndIndex, "endIndex", _name.c_str()); + + // Validate and apply start/end index carefully + if (tempStartIndex >= 0 && tempStartIndex < MAX_MANAGED_DEVICES && + tempEndIndex >= 0 && tempEndIndex < MAX_MANAGED_DEVICES && + tempStartIndex <= tempEndIndex) + { + m_startIndex.applyUpdate(tempStartIndex); + m_endIndex.applyUpdate(tempEndIndex); + // Ensure currentIndex is valid after loading new start/end + if (_currentIndex < m_startIndex.getValue() || _currentIndex > m_endIndex.getValue()) + { + _currentIndex = m_startIndex.getValue(); + } + } + else + { + Log.warningln(F("[%s] Invalid startIndex (%u) or endIndex (%u) from JSON. Using existing values: %u, %u"), _name.c_str(), tempStartIndex, tempEndIndex, m_startIndex.getValue(), m_endIndex.getValue()); + } + + bool wasEnabled = const_cast(this)->enabled(); + bool newEnabled = wasEnabled; + JsonUtils::parseJsonFieldBool(json, "enabled", newEnabled, "enabled", _name.c_str()); + if (newEnabled != wasEnabled) + { + enable(newEnabled); + } + return true; +} + +bool AmperageBudgetManager::load(const char *path) +{ + if (!LittleFS.begin()) + { + L_ERROR(F("[%s] Failed to initialize LittleFS for load."), _name.c_str()); + return false; + } + + File configFile = LittleFS.open(path, "r"); + if (!configFile) + { + Log.warningln(F("[%s] Settings file not found: %s. Using current (default) settings."), _name.c_str(), path); + return false; + } + + JsonDocument doc; + DeserializationError error = deserializeJson(doc, configFile); + configFile.close(); + + if (error) + { + L_ERROR(F("[%s] Failed to deserialize settings file %s: %s"), _name.c_str(), path, error.c_str()); + return false; + } + return fromJson(doc.as()); +} + +bool AmperageBudgetManager::save(const char *path) const +{ + if (!LittleFS.begin()) + { + L_ERROR(F("[%s] Failed to initialize LittleFS for save."), _name.c_str()); + return false; + } + + JsonDocument doc; + toJson(doc); + + File configFile = LittleFS.open(path, "w"); + if (!configFile) + { + L_ERROR(F("[%s] Failed to open settings file for writing: %s"), _name.c_str(), path); + return false; + } + + size_t bytesWritten = serializeJson(doc, configFile); + configFile.close(); + + if (bytesWritten == 0) + { + L_ERROR(F("[%s] Failed to write settings to file: %s"), _name.c_str(), path); + return false; + } + + return true; +} + +void AmperageBudgetManager::print() const +{ + /* + L_INFO(F("--- AmperageBudgetManager Values ---")); + L_INFO(F(" minHeatingDurationMs: %lu"), _minHeatingDurationMs); + L_INFO(F(" maxHeatingDurationMs: %lu"), _maxHeatingDurationMs); + L_INFO(F(" maxSimultaneousHeating: %u"), _maxSimultaneousHeating); + L_INFO(F(" windowOffset: %u"), _windowOffset); + L_INFO(F(" enabled: %s"), const_cast(this)->enabled() ? "Yes" : "No"); + L_INFO(F(" startIndex: %u"), _startIndex); + L_INFO(F(" endIndex: %u"), _endIndex); + L_INFO(F(" currentIndex: %u"), _currentIndex); + L_INFO(F(" numDevices: %u"), _numDevices); + L_INFO(F("--- End AmperageBudgetManager Values ---")); + */ +} + +void AmperageBudgetManager::onHeatupComplete() +{ + if (!_heatupPhaseComplete) + { + L_INFO(F("[%s] First heat-up complete. Switching mode from %d to %d"), _name.c_str(), m_mode.getValue(), m_postHeatupMode.getValue()); + _initialMode = m_mode.getValue(); + m_mode.update(m_postHeatupMode.getValue()); + _heatupPhaseComplete = true; + } +} + +void AmperageBudgetManager::_checkAllDevicesForHeatupCompletion() +{ + for (uint8_t i = 0; i < _numDevices; ++i) + { + OmronE5 *device = _devices[i]; + if (device == nullptr) + continue; + + bool isCurrentlyInHeatup = _checkHeatup(device); + if (_deviceInHeatup[i] && !isCurrentlyInHeatup) + { + onHeatupComplete(); + } + else if (_heatupPhaseComplete && isCurrentlyInHeatup && !_deviceInHeatup[i]) + { + // A device has fallen behind after the initial heatup phase was complete. + m_mode.update(_initialMode); + _heatupPhaseComplete = false; // Allow the cycle to complete again + } + _deviceInHeatup[i] = isCurrentlyInHeatup; + } +} +#endif \ No newline at end of file diff --git a/src/components/AmperageBudgetManager.h b/src/components/AmperageBudgetManager.h new file mode 100644 index 00000000..6d61be79 --- /dev/null +++ b/src/components/AmperageBudgetManager.h @@ -0,0 +1,202 @@ +#ifndef AMPERAGE_BUDGET_MANAGER_H +#define AMPERAGE_BUDGET_MANAGER_H + +#include +#include +#include +#include +#include + +#include "components/OmronE5.h" +#include "modbus/NetworkComponent.h" +#include "NetworkValue.h" +#include +#include + +#include "config-modbus.h" +#include "config.h" + +// Forward declaration +class OmronE5; + +// Default values +#define MIN_HEATING_DURATION_S 3 // 3 seconds +#define MAX_HEATING_DURATION_S 5 // 5 seconds +#define DEFAULT_MAX_SIMULTANEOUS_HEATING 2 // Default number of devices that can heat simultaneously +#define DEFAULT_WINDOW_OFFSET 1 // Default window offset +#define DEFAULT_START_INDEX 0 // Default start index for cycling +#define DEFAULT_END_INDEX (MAX_MANAGED_DEVICES - 1) // Default end index for cycling +#define DEFAULT_MAX_HEATING_OSCILLATING_S 15 // 60 seconds +#define STOP_ALL_DEVICES_WAIT_MS 50 // Wait time after stopping all devices + +// Modbus write boundaries +#define MB_MAX_TIME_MIN_S 1 // Minimum max time: 1s +#define MB_MAX_TIME_MAX_S (120 * 60) // Maximum max time: 2 hours + +#define MB_MIN_TIME_MIN_S 1 // Minimum min time: 1s +#define MB_MIN_TIME_MAX_S 60 // Maximum min time: 60s + +#define AMP_BUDGET_MB_COUNT 12 // m_enabled + 11 custom values + +enum E_AMPERAGE_MODE +{ + E_AM_CYCLE_ALL, // Cycle through all devices + E_AM_CYCLE_SP, // Cycle through partitions (REG_OFFSET_MAX_SIM), advance when SP - DEADBAND is reached + E_AM_CYCLE_SP_ANY, // Heat any N devices that require it + E_AM_CYCLE_SP_MOST_URGENT // Heat most urgent N devices, within window +}; + +enum E_OP_FLAGS : uint16_t +{ + E_SQ_NONE = 0, + E_SQ_RUNNING_PROFILES_ONLY = 1 << 0, + E_SQ_VERBOSE = 1 << 1, + E_SQ_USER = 1 << 2, +}; + +class AmperageBudgetManager : public NetworkComponent +{ +public: + typedef bool (*CanUsePIDCallback)(Component *owner, OmronE5 *device); + typedef void (*OnWarmupCompleteCallback)(Component *owner); + + enum E_MB_OFFSETS + { + REG_OFFSET_INFO = E_NVC_USER, + REG_OFFSET_MIN_TIME, + REG_OFFSET_MAX_TIME, + REG_OFFSET_MAX_SIM, + REG_OFFSET_OFFSET, + REG_OFFSET_START_INDEX, + REG_OFFSET_END_INDEX, + REG_OFFSET_MODE, + REG_OFFSET_MAX_TIME_OSCILLATING, + REG_OFFSET_POST_HEATUP_MODE, + REG_OFFSET_OP_FLAGS + }; + + AmperageBudgetManager(Component *owner, uint16_t baseAddress = MB_ADDR_AMPERAGE_BUDGET_BASE); + virtual ~AmperageBudgetManager() = default; + + void setCanUseCallback(CanUsePIDCallback cb) + { + _canUseCallback = cb; + } + + void setOnWarmupCompleteCallback(OnWarmupCompleteCallback cb) + { + _onWarmupCompleteCallback = cb; + } + + bool addManagedDevice(OmronE5 *device); + virtual short setup() override; + virtual short loop() override; + virtual short info(short val0, short val1) override; + virtual short reset() override; + + virtual void onCycleStart(const std::vector &activeDevices); + virtual void onCycleEnd(const std::vector &activeDevices); + virtual void onHeatupComplete(); + + // Max simultaneous heating control + uint8_t getMaxSimultaneousHeating() const { return m_maxSimultaneousHeating.getValue(); } + void setMaxSimultaneousHeating(uint8_t value) + { + if (value > 0 && value <= MAX_MANAGED_DEVICES) + { + m_maxSimultaneousHeating.update(value); + } + } + + // Modbus interface + virtual short mb_tcp_write(MB_Registers *reg, short value) override; + virtual short mb_tcp_read(MB_Registers *reg) override; + virtual short serial_register(Bridge *bridge) override; + + // Persistence methods + void toJson(JsonDocument &doc) const; + bool fromJson(const JsonObject &json); + bool load(const char *path); + bool save(const char *path) const; + void print() const; + +protected: + virtual void notifyStateChange() override; // Override to handle device stopping + +private: + CanUsePIDCallback _canUseCallback; + OnWarmupCompleteCallback _onWarmupCompleteCallback; + static const uint8_t MAX_MANAGED_DEVICES = NUM_OMRON_DEVICES; + OmronE5 *_devices[MAX_MANAGED_DEVICES]; + uint8_t _numDevices; + uint8_t _currentIndex; // Current device index in the round-robin + + // Configurable parameters + NetworkValue m_minHeatingDurationS; + NetworkValue m_maxHeatingDurationS; + NetworkValue m_maxHeatingDurationOscillatingS; + NetworkValue m_maxSimultaneousHeating; + NetworkValue m_windowOffset; + NetworkValue m_mode; + NetworkValue m_postHeatupMode; + NetworkValue m_startIndex; + NetworkValue m_endIndex; + NetworkValue m_opFlags; + + E_AMPERAGE_MODE _initialMode; + + uint32_t _deviceStartTimes[MAX_MANAGED_DEVICES]; + bool _deviceHeating[MAX_MANAGED_DEVICES]; + bool _deviceInHeatup[MAX_MANAGED_DEVICES]; + bool _heatupPhaseComplete; + String _name; + + millis_t _lastLoopTime; + millis_t _lastDebugLogTime; + uint32_t _lastStopTimeLog[MAX_MANAGED_DEVICES]; + std::vector _activeDevices; + + bool _checkHeatup(OmronE5 *device); + void _stopAllDevices(); + int16_t _stoppingIndex; + uint32_t _lastStopTimestamp; + void _stopDevice(uint8_t deviceIndex, const char *reason); + void _checkAllDevicesForHeatupCompletion(); + + void _loopCycleAll(); + void _loopCycleSp(); + void _loopCycleSpAny(); + void _loopCycleSpMostUrgent(); + + // Validation methods + bool _validateMaxTime(short value) const + { + return value >= MB_MAX_TIME_MIN_S && value <= MB_MAX_TIME_MAX_S; + } + bool _validateMinTime(short value) const + { + return value >= MB_MIN_TIME_MIN_S && value <= MB_MIN_TIME_MAX_S; + } + bool _validateMaxTimeOscillating(short value) const + { + return value >= 1 && value <= 3600; // 1s to 1hr + } + bool _validateMaxSim(short value) const + { + return value >= 1 && value <= MAX_MANAGED_DEVICES; + } + bool _validateOffset(short value) const + { + return value >= 1 && value <= MAX_MANAGED_DEVICES; + } + bool _validateStartIndex(short value) const + { + return value >= 0 && value < MAX_MANAGED_DEVICES && value <= m_endIndex.getValue(); + } + bool _validateEndIndex(short value) const + { + return value >= 0 && value < MAX_MANAGED_DEVICES && value >= m_startIndex.getValue(); + } +}; + +#endif \ No newline at end of file diff --git a/src/components/InfluxDB.cpp b/src/components/InfluxDB.cpp new file mode 100644 index 00000000..1e2b35bd --- /dev/null +++ b/src/components/InfluxDB.cpp @@ -0,0 +1,90 @@ +#include "InfluxDB.h" +#include "config-extensions.h" + +#ifdef ENABLE_INFLUXDB + +InfluxDB::InfluxDB(Component *owner, short _id) + : Component("InfluxDB", _id, Component::COMPONENT_DEFAULT, owner) +{ + setFlag(OBJECT_RUN_FLAGS::E_OF_BROKER); +} + +void InfluxDB::_connect() +{ + _lastConnectionAttempt = millis(); + Log.infoln("InfluxDB: connecting..."); + if (_client.validateConnection()) + { + Log.infoln("InfluxDB: connected!"); + _isConnected = true; + _reconnectInterval = 1000; + Log.infoln("InfluxDB: connected to %s", INFLUXDB_URL); + } + else + { + Log.warningln("InfluxDB: connection failed: %s", _client.getLastErrorMessage().c_str()); + _isConnected = false; + // Exponential backoff + _reconnectInterval *= 2; + if (_reconnectInterval > _maxReconnectInterval) + { + _reconnectInterval = _maxReconnectInterval; + } + Log.warningln("InfluxDB: next reconnect attempt in %lu ms", _reconnectInterval); + } +} + +short InfluxDB::setup() +{ + Component::setup(); + Log.verboseln(F("InfluxDB::setup - ID %d"), id); + _client.setConnectionParams(INFLUXDB_URL, INFLUXDB_ORG, INFLUXDB_BUCKET, INFLUXDB_TOKEN); + _client.setInsecure(true); + _connect(); + return E_OK; +} + +short InfluxDB::loop() +{ + if (!_isConnected) + { + if (millis() - _lastConnectionAttempt > _reconnectInterval) + { + _connect(); + } + } + return E_OK; +} + +short InfluxDB::info(short flags, short val) +{ + Log.verboseln("InfluxDB::info - ID: %d, Connected: %s", id, _isConnected ? "true" : "false"); + return E_OK; +} + +short InfluxDB::debug() +{ + return info(0, 0); +} + +short InfluxDB::write(Point &influxDbPoint) +{ + if (!_isConnected) + { + Log.warningln(F("InfluxDB: not connected, cannot write point.")); + return 1; // Not connected error + } + + if (!_client.writePoint(influxDbPoint)) + { + Log.warningln(F("InfluxDB: failed to write point: %s"), _client.getLastErrorMessage().c_str()); + if (_client.isBufferFull()) + { + Log.warningln(F("InfluxDB: buffer is full. Flushing...")); + _client.flushBuffer(); + } + return 2; // Write error + } + return E_OK; +} +#endif \ No newline at end of file diff --git a/src/components/InfluxDB.h b/src/components/InfluxDB.h new file mode 100644 index 00000000..3b563652 --- /dev/null +++ b/src/components/InfluxDB.h @@ -0,0 +1,31 @@ +#ifndef INFLUXDB_H +#define INFLUXDB_H + +#include "config.h" +#include +#include + +#ifdef ENABLE_INFLUXDB +#include +class InfluxDB : public Component +{ +private: + InfluxDBClient _client; + bool _isConnected = false; + unsigned long _lastConnectionAttempt = 0; + unsigned long _reconnectInterval = 1000; // Initial interval 1s + static const unsigned long _maxReconnectInterval = 25000; // Max 25s + + void _connect(); + +public: + InfluxDB(Component *owner, short _id); + + short setup() override; + short info(short flags = 0, short val = 0) override; + short debug() override; + short loop() override; + short write(Point &influxDbPoint); +}; +#endif +#endif // ENABLE_INFLUXDB diff --git a/src/components/ModbusMirror.cpp b/src/components/ModbusMirror.cpp new file mode 100644 index 00000000..562822b1 --- /dev/null +++ b/src/components/ModbusMirror.cpp @@ -0,0 +1,160 @@ +#include "components/ModbusMirror.h" +#include "PHApp.h" +#include +#include + +#ifdef ENABLE_MODBUS_MIRROR + +static ModbusMirror* instance = nullptr; + +void ModbusMirror::onData(ModbusMessage response, uint32_t token) { + if (instance && !instance->_isConnected) { + instance->_isConnected = true; + instance->m_status.update(MBM_STATUS_CONNECTED); + LS_INFO("ModbusMirror: Connected to server %s:%d.", instance->_serverIP.toString().c_str(), instance->_serverPort); + } +} + +void ModbusMirror::onError(Error error, uint32_t token) { + ModbusError me(error); + LS_ERROR("ModbusMirror::onError: %02X - %s, token: %08X", (int)me, (const char *)me, token); + if (instance) { + instance->_isConnected = false; + if (me.operator int() == (int)MB_Error::Timeout) { + instance->m_status.update(MBM_STATUS_TIMEOUT); + } else { + instance->m_status.update(MBM_STATUS_DISCONNECTED); + } + } +} + +ModbusMirror::ModbusMirror(PHApp* owner, uint16_t id) : + NetworkComponent(MB_ADDR_MB_MIRROR_START, "ModbusMirror", id, Component::COMPONENT_DEFAULT, owner), + _serverIP(), + _serverPort(MODBUS_MIRROR_SERVER_PORT), + _mbClient(nullptr), + _isConnected(false), + _lastConnectionAttempt(0), + _initialConnectionAttempt(0), + m_command(this, 0, "Command"), + m_server_id(this, 1, "ServerID"), + m_status(this, 2, "Status") +{ + instance = this; + + setNetCapability(OBJECT_NET_CAPS::E_NCAPS_MODBUS); + + IPAddress ip(MODBUS_MIRROR_SERVER_IP); + _serverIP = ip; + + _mbClient = new ModbusClientTCPasync(_serverIP, _serverPort); + + _mbClient->onDataHandler(&ModbusMirror::onData); + _mbClient->onErrorHandler(&ModbusMirror::onError); + _mbClient->setTimeout(10000); + _mbClient->setIdleTimeout(60000); + m_server_id.update(1); + m_status.update(MBM_STATUS_DISCONNECTED); +} + +ModbusMirror::~ModbusMirror() { + delete _mbClient; + instance = nullptr; +} + +short ModbusMirror::setup() { + NetworkComponent::setup(); + _initialConnectionAttempt = 0; + const uint16_t baseAddr = mb_tcp_base_address(); + m_command.initModbus(baseAddr + MB_OFS_COMMAND, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, m_command.name.c_str(), this->name.c_str()); + m_server_id.initModbus(baseAddr + MB_OFS_SERVER_ID, 1, this->id, this->slaveId, FN_WRITE_HOLD_REGISTER, m_server_id.name.c_str(), this->name.c_str()); + m_status.initModbus(baseAddr + MB_OFS_STATUS, 1, this->id, this->slaveId, FN_READ_HOLD_REGISTER, m_status.name.c_str(), this->name.c_str()); + registerBlock(m_command.getRegisterInfo()); + registerBlock(m_server_id.getRegisterInfo()); + registerBlock(m_status.getRegisterInfo()); + return E_OK; +} + +short ModbusMirror::loop() { + Component::loop(); + if (!_isConnected) { + unsigned long now = millis(); + if (_initialConnectionAttempt == 0) { + _initialConnectionAttempt = now; + } + + if (now - _initialConnectionAttempt > MODBUS_MIRROR_MAX_RECONNECT_TIME_MS) { + L_ERROR("ModbusMirror: Max reconnect time exceeded. Giving up."); + m_status.update(MBM_STATUS_TIMEOUT); + disable(); + return E_OK; + } + + if (now - _lastConnectionAttempt > MODBUS_MIRROR_RECONNECT_INTERVAL_MS) { + _lastConnectionAttempt = now; + L_INFO("ModbusMirror: Attempting to connect to %s:%d...", _serverIP.toString().c_str(), _serverPort); + m_status.update(MBM_STATUS_CONNECTING); + + uint32_t token = millis(); + Error err = _mbClient->addRequest(token, m_server_id.getValue(), READ_HOLD_REGISTER, 0, 1); + if (err != SUCCESS) { + ModbusError e(err); + L_ERROR("ModbusMirror: Error creating request: %02X - %s", (int)e, (const char *)e); + m_status.update(MBM_STATUS_DISCONNECTED); + } + } + } + return E_OK; +} + +ModbusClientTCPasync* ModbusMirror::getClient() { + return _mbClient; +} + +short ModbusMirror::mb_tcp_write(MB_Registers *reg, short networkValue) +{ + short result = NetworkComponent::mb_tcp_write(reg, networkValue); + if (result != E_NOT_IMPLEMENTED) { + return result; + } + + uint16_t address = reg->startAddress; + if (address == (_baseAddress + MB_OFS_COMMAND)) + { + m_command.update(networkValue); + // Handle command + return E_OK; + } + if (address == (_baseAddress + MB_OFS_SERVER_ID)) + { + m_server_id.update(networkValue); + return E_OK; + } + return E_INVALID_PARAMETER; +} + +short ModbusMirror::mb_tcp_read(MB_Registers *reg) +{ + short result = NetworkComponent::mb_tcp_read(reg); + if (result != E_NOT_IMPLEMENTED) { + return result; + } + + uint16_t address = reg->startAddress; + if (address == (_baseAddress + MB_OFS_COMMAND)) + { + return m_command.getValue(); + } + if (address == (_baseAddress + MB_OFS_SERVER_ID)) + { + return m_server_id.getValue(); + } + if (address == (_baseAddress + MB_OFS_STATUS)) + { + return m_status.getValue(); + } + return 0; +} + + +#endif // ENABLE_MODBUS_MIRROR \ No newline at end of file diff --git a/src/components/ModbusMirror.h b/src/components/ModbusMirror.h new file mode 100644 index 00000000..9a097bcc --- /dev/null +++ b/src/components/ModbusMirror.h @@ -0,0 +1,68 @@ +#ifndef MODBUS_MIRROR_H +#define MODBUS_MIRROR_H + +#include "config.h" +#ifdef ENABLE_MODBUS_MIRROR + +#include "modbus/NetworkComponent.h" +#include +#include "config-modbus.h" +#include "NetworkValue.h" + +#define MODBUS_MIRROR_MB_COUNT 4 // command, server_id, status + +class PHApp; + +class ModbusMirror : public NetworkComponent { +public: + enum E_MBM_CMD { + E_MBM_INFO, // (stub) + E_MBM_SYNC + }; + + enum E_MBM_Status { + MBM_STATUS_DISCONNECTED, + MBM_STATUS_CONNECTING, + MBM_STATUS_CONNECTED, + MBM_STATUS_TIMEOUT + }; + + enum E_MB_Offset { + MB_OFS_COMMAND = E_NVC_USER + 0, + MB_OFS_SERVER_ID = E_NVC_USER + 1, + MB_OFS_STATUS = E_NVC_USER + 2 + }; + + ModbusMirror(PHApp* owner, uint16_t id); + ~ModbusMirror() override; + + short setup() override; + short loop() override; + + short mb_tcp_write(MB_Registers *reg, short networkValue) override; + short mb_tcp_read(MB_Registers *reg) override; + + ModbusClientTCPasync* getClient(); + +private: + friend void onData(ModbusMessage response, uint32_t token); + friend void onError(Error error, uint32_t token); + + static void onData(ModbusMessage response, uint32_t token); + static void onError(Error error, uint32_t token); + + IPAddress _serverIP; + uint16_t _serverPort; + ModbusClientTCPasync* _mbClient; + + bool _isConnected; + unsigned long _lastConnectionAttempt; + unsigned long _initialConnectionAttempt; + + NetworkValue m_command; + NetworkValue m_server_id; + NetworkValue m_status; +}; + +#endif // ENABLE_MODBUS_MIRROR +#endif // MODBUS_MIRROR_H \ No newline at end of file diff --git a/src/components/RestServer.cpp b/src/components/RestServer.cpp index 1c0d1603..191d6d6b 100644 --- a/src/components/RestServer.cpp +++ b/src/components/RestServer.cpp @@ -1,4 +1,5 @@ #include "config.h" // Application configuration +#include "version.h" // #define WS_MAX_QUEUED_MESSAGES 512 @@ -684,7 +685,16 @@ void RESTServer::getSystemInfoHandler(AsyncWebServerRequest *request) { AsyncResponseStream *response = request->beginResponseStream("application/json"); JsonDocument doc; - doc["version"] = "3ce112f"; +#ifdef VERSION + doc["version"] = VERSION; +#else + doc["version"] = "unknown"; +#endif +#ifdef PACKAGE_URL + doc["url"] = PACKAGE_URL; +#else + doc["url"] = "unknown"; +#endif doc["board"] = BOARD_NAME; doc["uptime"] = millis() / 1000; doc["timestamp"] = millis();