#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 60 #define REVERT_TO_HEATUP_DEADBAND 15 short AmperageBudgetManager::reset() { _stopAllDevices(); _currentIndex = m_startIndex.getValue(); _heatupPhaseComplete = false; for (uint8_t i = 0; i < MAX_MANAGED_DEVICES; ++i) { _deviceInHeatup[i] = false; } _batchDoneConfirmationCount = 0; 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_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_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), _batchDoneConfirmationCount(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_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_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; } // Fix: hasError() is cumulative. We only want to stop if the device is truly unreachable (Timeout). // Other errors (CRC, Collision) are transient and should be ignored to prevent premature abortion. if (device->getLastErrorCode() == (uint16_t)MB_Error::Timeout) { 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() && _checkHeatup(_devices[tempIndex])) { 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 = 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() && _checkHeatup(_devices[tempIndex])) { 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) { // Debounce: Only advance if the "Done" state persists to avoid deadband flutter _batchDoneConfirmationCount++; if (_batchDoneConfirmationCount >= BATCH_DONE_THRESHOLD) { advanceCurrentIndex(); windowIndices = getWindowDeviceIndices(); _batchDoneConfirmationCount = 0; } } else { _batchDoneConfirmationCount = 0; } 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; 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]) { // Enforce minimum heating duration to prevent rapid cycling uint32_t now_s = now / 1000; if (now_s - _deviceStartTimes[i] < m_minHeatingDurationS.getValue()) { continue; // Keep running } _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()); uint32_t now_s = now / 1000; std::vector lingeringIndices; // 1. Identify "Lingering" devices (must keep running due to MinDuration) for (uint8_t i = 0; i < _numDevices; i++) { if (_devices[i] && _devices[i]->enabled() && _deviceHeating[i]) { if (now_s - _deviceStartTimes[i] < m_minHeatingDurationS.getValue()) { lingeringIndices.push_back(i); } } } std::vector devicesToHeat; // 2. Reserve slots for lingering devices for (uint8_t idx : lingeringIndices) { devicesToHeat.push_back(idx); } // 3. Fill remaining slots with most urgent devices for (size_t i = 0; i < urgentDevices.size(); ++i) { if (devicesToHeat.size() >= m_maxSimultaneousHeating.getValue()) { break; } bool isLingering = false; for (uint8_t lIdx : lingeringIndices) { if (urgentDevices[i].index == lIdx) { isLingering = true; break; } } if (!isLingering) { devicesToHeat.push_back(urgentDevices[i].index); } } 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 = m_maxHeatingDurationS.getValue(); uint32_t elapsed = now_s - _deviceStartTimes[i]; if (elapsed >= maxDuration) { _devices[i]->stop(); _deviceHeating[i] = false; return; // Action taken } if (!shouldHeat) { // Enforce minimum heating duration if (elapsed < m_minHeatingDurationS.getValue()) { continue; // Keep running } _devices[i]->stop(); _deviceHeating[i] = false; return; // Action taken } } 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\n"), m_minHeatingDurationS.getValue(), m_maxHeatingDurationS.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_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_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["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["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(); 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::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 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) { _heatupPhaseComplete = true; } } void AmperageBudgetManager::_checkAllDevicesForHeatupCompletion() { bool anyDeviceInHeatup = false; bool excessiveDropDetected = false; for (uint8_t i = 0; i < _numDevices; ++i) { OmronE5 *device = _devices[i]; if (device == nullptr) continue; bool isCurrentlyInHeatup = _checkHeatup(device); _deviceInHeatup[i] = isCurrentlyInHeatup; if (isCurrentlyInHeatup) { anyDeviceInHeatup = true; if (_heatupPhaseComplete) { // A device has fallen behind after the initial heatup phase was complete. // Only revert if the drop is significant to avoid oscillation. uint16_t sp, pv; // Access OmronE5 SP/PV safely if (device->getSP(sp) && device->getPV(pv)) { // Unsigned arithmetic safety check if (sp > pv && (sp - pv) > REVERT_TO_HEATUP_DEADBAND) { excessiveDropDetected = true; L_INFO(F("[%s] Device %d dropped significantly below SP (Diff > %d). Reverting to initial mode %d"), _name.c_str(), i, REVERT_TO_HEATUP_DEADBAND, _initialMode); } } } } } if (!_heatupPhaseComplete) { if (!anyDeviceInHeatup) { onHeatupComplete(); } } else { if (excessiveDropDetected) { m_mode.update(_initialMode); _heatupPhaseComplete = false; // Allow the cycle to complete again } } } size_t AmperageBudgetManager::getBinaryState(uint8_t *buffer, size_t maxLen) { if (maxLen < sizeof(StatePacket)) { return 0; } StatePacket *packet = (StatePacket *)buffer; packet->timestamp = millis(); packet->currentIdx = _currentIndex; packet->numDevices = _numDevices; packet->heatupComplete = _heatupPhaseComplete ? 1 : 0; // Zero out devices first memset(packet->devices, 0, sizeof(packet->devices)); uint32_t now_s = millis() / 1000; for (uint8_t i = 0; i < _numDevices && i < NUM_OMRON_DEVICES; ++i) { packet->devices[i].index = i; if (_devices[i]) { packet->devices[i].enabled = _devices[i]->enabled() ? 1 : 0; } else { packet->devices[i].enabled = 0; } packet->devices[i].heating = _deviceHeating[i] ? 1 : 0; packet->devices[i].heatup = _deviceInHeatup[i] ? 1 : 0; if (_deviceHeating[i]) { packet->devices[i].elapsed = now_s - _deviceStartTimes[i]; } else { packet->devices[i].elapsed = 0; } } return sizeof(StatePacket); } #endif