diff --git a/Inferno.Api/Services/FireMinder.cs b/Inferno.Api/Services/FireMinder.cs
index cf07ab8..c89b34d 100644
--- a/Inferno.Api/Services/FireMinder.cs
+++ b/Inferno.Api/Services/FireMinder.cs
@@ -37,10 +37,25 @@ public class FireMinder : IDisposable
double _ignitionHigh;
bool _fireStarted;
int _ignitionTemp;
+ ///
+ /// Grill temperature at which the fire was first declared started, captured
+ /// once on the initial ignition. Unlike — which the
+ /// recovery path raises to the fire-check temp when relighting a struggling
+ /// fire — this stays anchored to where the fire actually caught. That lets Sear
+ /// gate its full-feed transition on a margin above the catch temp instead of a
+ /// fixed absolute temperature that a cold or windy firepot may never reach.
+ ///
+ int _initialIgnitionTemp;
bool _initialIgnition;
public bool IsFireHealthy => !_fireCheck;
public bool IsFireStarted => _fireStarted;
+ ///
+ /// Grill temp (F) at which the fire was first declared started. Anchored to the
+ /// initial catch — never raised by recovery relights — so callers can derive a
+ /// relative establish threshold from it.
+ ///
+ public int InitialIgnitionTemp => _initialIgnitionTemp;
public bool IsReigniting => _fireCheck && _igniter.IsOn;
// Recovery dominates: never report a lid-open while we're actively recovering,
// so the Smoker stays on the aggressive RecoveryFeed instead of the floor.
@@ -63,6 +78,9 @@ public void ResetFireStatus()
_fireCheck = false;
_initialIgnition = true;
_ignitionTemp = 150;
+ // Conservative default so Sear's relative establish gate stays high until
+ // the fire actually catches (and overwrites this with the real catch temp).
+ _initialIgnitionTemp = 150;
_belowCheckSince = null;
_recoveryHigh = 0;
_ignitionHigh = 0;
@@ -177,6 +195,12 @@ internal void Tick()
if(_smoker.Temps.GrillTemp >= _ignitionTemp)
{
// The fire has started, make sure the igniter is off
+ if (!_fireStarted)
+ {
+ // Capture where the fire caught, once. Recovery may later raise
+ // _ignitionTemp, but this anchor must stay at the initial catch.
+ _initialIgnitionTemp = _ignitionTemp;
+ }
_fireStarted = true;
_igniter.Off();
}
diff --git a/Inferno.Api/Services/Smoker.cs b/Inferno.Api/Services/Smoker.cs
index 657d8c5..939437e 100644
--- a/Inferno.Api/Services/Smoker.cs
+++ b/Inferno.Api/Services/Smoker.cs
@@ -27,6 +27,16 @@ public class Smoker : ISmoker, IDisposable
int _maxGrillTemp = 425;
+ ///
+ /// In Sear, how far above the ignition temperature the grill must climb before
+ /// the auger switches from the gentle establishing feed to a continuous full
+ /// feed. A margin relative to where the fire actually caught — not a fixed
+ /// absolute temp — so a cold or windy start (where the firepot settles lower)
+ /// still reaches full feed instead of stalling under the old absolute 180F gate.
+ /// Lower this if the worst windy days still stall before full feed.
+ ///
+ int _searEstablishMargin = 25;
+
///
/// Timeout for the blower to run after shutdown.
///
@@ -489,9 +499,10 @@ private async Task Sear()
return;
}
- if (_rtdArray.GrillTemp < _minSetPoint)
+ int establishTemp = _fireMinder.InitialIgnitionTemp + _searEstablishMargin;
+ if (_rtdArray.GrillTemp < establishTemp)
{
- Debug.WriteLine($"Sear: Grill temp {_rtdArray.GrillTemp} below {_minSetPoint}. Diverting to SMOKE to establish fire.");
+ Debug.WriteLine($"Sear: Grill temp {_rtdArray.GrillTemp} below establish temp {establishTemp} (ignition {_fireMinder.InitialIgnitionTemp} + {_searEstablishMargin}). Diverting to SMOKE to establish fire.");
await Smoke();
return;
}
diff --git a/Inferno.Tests/FireMinderTests.cs b/Inferno.Tests/FireMinderTests.cs
index 868de78..41fa360 100644
--- a/Inferno.Tests/FireMinderTests.cs
+++ b/Inferno.Tests/FireMinderTests.cs
@@ -182,6 +182,60 @@ public void ResetFireStatus_ClearsState()
Assert.False(fm.IsLidOpen);
}
+ [Fact]
+ public void InitialIgnitionTemp_DefaultsToFloor_BeforeFireStarts()
+ {
+ var smoker = new FakeSmoker { Mode = SmokerMode.Sear, SetPoint = 400 };
+ var igniter = new FakeRelay();
+ var fm = new FireMinder(smoker, igniter, autoStart: false);
+ fm.ResetFireStatus();
+
+ // Before the fire catches, the anchor sits at the 150F floor so Sear's
+ // relative establish gate stays conservative.
+ Assert.False(fm.IsFireStarted);
+ Assert.Equal(150, fm.InitialIgnitionTemp);
+ }
+
+ [Fact]
+ public void ColdStart_CapturesInitialIgnitionTempAtCatch()
+ {
+ var smoker = new FakeSmoker { Mode = SmokerMode.Sear, SetPoint = 400 };
+ var igniter = new FakeRelay();
+ var clock = new TestClock();
+ var fm = new FireMinder(smoker, igniter, () => clock.Now, autoStart: false);
+ fm.ResetFireStatus();
+
+ SetGrill(smoker, 75);
+ fm.Tick(); // igniter lights; ignition temp floors at 150
+ Assert.True(igniter.IsOn);
+ Assert.False(fm.IsFireStarted);
+
+ SetGrill(smoker, 152);
+ fm.Tick(); // crosses 150 → fire started
+
+ Assert.True(fm.IsFireStarted);
+ Assert.Equal(150, fm.InitialIgnitionTemp);
+ }
+
+ [Fact]
+ public void InitialIgnitionTemp_UnchangedByRecoveryRelight()
+ {
+ // EstablishedFire catches cold (grill jumps to 200 over the 150 floor).
+ var (fm, smoker, igniter, clock) = EstablishedFire();
+ Assert.Equal(150, fm.InitialIgnitionTemp);
+
+ // Trip recovery: the relight raises the internal ignition threshold to the
+ // fire-check temp (195), but the initial-catch anchor must stay put so Sear's
+ // establish gate doesn't drift upward after a recovery.
+ SetGrill(smoker, 180);
+ fm.Tick();
+ clock.Advance(TimeSpan.FromSeconds(46));
+ fm.Tick();
+ Assert.True(fm.IsReigniting);
+
+ Assert.Equal(150, fm.InitialIgnitionTemp);
+ }
+
[Fact]
public void BriefDip_RecoversBeforeDebounce_DoesNotTrip()
{