firmware-base/src/components/AmperageBudgetManager.cpp

1388 lines
43 KiB
C++

#include "config.h"
#ifdef ENABLE_AMPERAGE_BUDGET_MANAGER
#include <vector>
#include <pid_constants.h>
#include <Bridge.h>
#include <modbus/ModbusTCP.h>
#include <json.h>
#include <functional>
#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<uint8_t> 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<uint8_t>
{
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> 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<DeviceUrgency> 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<uint8_t> 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<uint8_t> 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<OmronE5 *> &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<OmronE5 *> &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<JsonObject>();
obj["minHeatingDurationS"] = m_minHeatingDurationS.getValue();
obj["maxHeatingDurationS"] = m_maxHeatingDurationS.getValue();
obj["maxSimultaneousHeating"] = m_maxSimultaneousHeating.getValue();
obj["windowOffset"] = m_windowOffset.getValue();
obj["enabled"] = const_cast<AmperageBudgetManager *>(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<AmperageBudgetManager *>(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<JsonObject>());
}
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<AmperageBudgetManager*>(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