From 30df637a0360fcce22bb16cfdc073cd7daba0857 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 16:49:14 +0000 Subject: [PATCH] Add cook data logging foundation (SQLite history + sessions API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records every cook as a session with a time-series of samples, the foundation for future features (probe alarms, done-time prediction, history graphs). Nothing was previously persisted — temps and mode were in-memory only and lost on every restart. - SqliteCookLogStore: single long-lived connection, WAL + synchronous=NORMAL and batched transactions to bound SD-card writes; schema applied on init. - CookLogger: background loop mirroring DisplayUpdater/FireMinder. Samples ISmoker.Status every ~10s and infers session boundaries from mode transitions, so it stays fully decoupled from the safety-critical state machine. Resumes an in-progress session (or closes an orphan) on restart. - SessionsController: list / active / detail (time-bounded) / CSV export / label endpoints under /api/sessions. - Program.cs: wire store + logger as singletons; dispose logger -> smoker -> store -> gpio so logging flushes before teardown. - Inferno.Deploy: create ~/inferno/data on the Pi (lives outside the publish dirs so history survives upgrades) before the api service starts. - Tests: 28 new tests covering session open/close on mode transitions, startup resume/orphan handling, sample mapping, batching, and peak tracking. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018HrUqCycauyXrhgs3o6KtN --- Inferno.Api/Controllers/SessionsController.cs | 98 ++++++ Inferno.Api/Inferno.Api.csproj | 1 + Inferno.Api/Interfaces/ICookLogStore.cs | 40 +++ Inferno.Api/Models/CookSample.cs | 21 ++ Inferno.Api/Models/CookSessionDto.cs | 17 + Inferno.Api/Program.cs | 32 +- Inferno.Api/Services/CookLogger.cs | 219 +++++++++++++ Inferno.Api/Services/SqliteCookLogStore.cs | 305 ++++++++++++++++++ Inferno.Deploy/Program.cs | 16 +- Inferno.Tests/CookLoggerTests.cs | 268 +++++++++++++++ Inferno.Tests/SqliteCookLogStoreTests.cs | 137 ++++++++ 11 files changed, 1148 insertions(+), 6 deletions(-) create mode 100644 Inferno.Api/Controllers/SessionsController.cs create mode 100644 Inferno.Api/Interfaces/ICookLogStore.cs create mode 100644 Inferno.Api/Models/CookSample.cs create mode 100644 Inferno.Api/Models/CookSessionDto.cs create mode 100644 Inferno.Api/Services/CookLogger.cs create mode 100644 Inferno.Api/Services/SqliteCookLogStore.cs create mode 100644 Inferno.Tests/CookLoggerTests.cs create mode 100644 Inferno.Tests/SqliteCookLogStoreTests.cs diff --git a/Inferno.Api/Controllers/SessionsController.cs b/Inferno.Api/Controllers/SessionsController.cs new file mode 100644 index 0000000..fd839d5 --- /dev/null +++ b/Inferno.Api/Controllers/SessionsController.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using System.Text; +using Inferno.Api.Interfaces; +using Inferno.Api.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Inferno.Api.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class SessionsController : ControllerBase + { + readonly ICookLogStore _store; + + public SessionsController(ICookLogStore store) + { + _store = store; + } + + // GET api/sessions + [HttpGet] + public ActionResult> List() + { + return Ok(_store.ListSessions()); + } + + // GET api/sessions/active + [HttpGet("active")] + public ActionResult Active() + { + var id = _store.GetActiveSessionId(); + if (id == null) + { + return NoContent(); + } + + var session = _store.GetSession(id.Value); + return session == null ? NoContent() : Ok(session); + } + + // GET api/sessions/{id}?from=&to= + [HttpGet("{id:long}")] + public ActionResult Get(long id, [FromQuery] DateTime? from, [FromQuery] DateTime? to) + { + var session = _store.GetSession(id); + if (session == null) + { + return NotFound(); + } + + return Ok(new { session, samples = _store.GetSamples(id, from, to) }); + } + + // GET api/sessions/{id}/export.csv + [HttpGet("{id:long}/export.csv")] + public ActionResult ExportCsv(long id, [FromQuery] DateTime? from, [FromQuery] DateTime? to) + { + var session = _store.GetSession(id); + if (session == null) + { + return NotFound(); + } + + var samples = _store.GetSamples(id, from, to); + var csv = new StringBuilder(); + csv.AppendLine("timestamp,grill_temp,probe_temp,mode,setpoint,pvalue,auger_on,blower_on,igniter_on,fire_healthy,preheated"); + foreach (var s in samples) + { + csv.Append(s.Timestamp.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)).Append(',') + .Append(s.GrillTemp.ToString(CultureInfo.InvariantCulture)).Append(',') + .Append(s.ProbeTemp.ToString(CultureInfo.InvariantCulture)).Append(',') + .Append(s.Mode).Append(',') + .Append(s.SetPoint).Append(',') + .Append(s.PValue).Append(',') + .Append(s.AugerOn ? 1 : 0).Append(',') + .Append(s.BlowerOn ? 1 : 0).Append(',') + .Append(s.IgniterOn ? 1 : 0).Append(',') + .Append(s.FireHealthy ? 1 : 0).Append(',') + .Append(s.Preheated ? 1 : 0).Append('\n'); + } + + return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"inferno-session-{id}.csv"); + } + + // POST api/sessions/{id}/label + [HttpPost("{id:long}/label")] + public ActionResult SetLabel(long id, [FromBody] string label) + { + if (_store.GetSession(id) == null) + { + return NotFound(); + } + + _store.SetLabel(id, label); + return Accepted(); + } + } +} diff --git a/Inferno.Api/Inferno.Api.csproj b/Inferno.Api/Inferno.Api.csproj index 4d32fb8..d43ff9b 100644 --- a/Inferno.Api/Inferno.Api.csproj +++ b/Inferno.Api/Inferno.Api.csproj @@ -12,6 +12,7 @@ + diff --git a/Inferno.Api/Interfaces/ICookLogStore.cs b/Inferno.Api/Interfaces/ICookLogStore.cs new file mode 100644 index 0000000..4bd5209 --- /dev/null +++ b/Inferno.Api/Interfaces/ICookLogStore.cs @@ -0,0 +1,40 @@ +using Inferno.Api.Models; + +namespace Inferno.Api.Interfaces +{ + /// + /// Persistence abstraction for cook history. Implementations own their storage + /// connection for their lifetime; call once before use + /// and dispose to flush and release resources. Methods are safe to call from the + /// logger loop and request threads concurrently. + /// + public interface ICookLogStore : IDisposable + { + /// Open the store, applying any one-time setup (connection, schema). + void Initialize(); + + /// Begin a new cook session. Returns its id. + long OpenSession(DateTime startUtc, string? label); + + /// Finalize a session with its end time and summary stats. + void CloseSession(long id, DateTime endUtc, double peakGrillTemp, double peakProbeTemp, int sampleCount); + + /// Persist a batch of samples in a single transaction. + void InsertSamples(long sessionId, IReadOnlyList samples); + + /// The id of the currently-open session (end_time IS NULL), or null. + long? GetActiveSessionId(); + + /// Set or rename a session's label. + void SetLabel(long id, string label); + + /// All sessions, most recent first. + IReadOnlyList ListSessions(); + + /// A single session, or null if it does not exist. + CookSessionDto? GetSession(long id); + + /// A session's samples ordered by timestamp, optionally bounded to [fromUtc, toUtc]. + IReadOnlyList GetSamples(long id, DateTime? fromUtc, DateTime? toUtc); + } +} diff --git a/Inferno.Api/Models/CookSample.cs b/Inferno.Api/Models/CookSample.cs new file mode 100644 index 0000000..bc49aaa --- /dev/null +++ b/Inferno.Api/Models/CookSample.cs @@ -0,0 +1,21 @@ +namespace Inferno.Api.Models +{ + /// + /// A single point-in-time snapshot of the smoker, captured by + /// and persisted as one row in the sample table. Timestamps are UTC. + /// + public class CookSample + { + public DateTime Timestamp { get; set; } + public double GrillTemp { get; set; } + public double ProbeTemp { get; set; } + public string Mode { get; set; } = ""; + public int SetPoint { get; set; } + public int PValue { get; set; } + public bool AugerOn { get; set; } + public bool BlowerOn { get; set; } + public bool IgniterOn { get; set; } + public bool FireHealthy { get; set; } + public bool Preheated { get; set; } + } +} diff --git a/Inferno.Api/Models/CookSessionDto.cs b/Inferno.Api/Models/CookSessionDto.cs new file mode 100644 index 0000000..a6cb4a7 --- /dev/null +++ b/Inferno.Api/Models/CookSessionDto.cs @@ -0,0 +1,17 @@ +namespace Inferno.Api.Models +{ + /// + /// A cook session: the span from entering a cooking mode until returning to Ready. + /// is null while the session is still active. Timestamps are UTC. + /// + public class CookSessionDto + { + public long Id { get; set; } + public DateTime StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string? Label { get; set; } + public double? PeakGrillTemp { get; set; } + public double? PeakProbeTemp { get; set; } + public int SampleCount { get; set; } + } +} diff --git a/Inferno.Api/Program.cs b/Inferno.Api/Program.cs index 7c1619c..6eae469 100644 --- a/Inferno.Api/Program.cs +++ b/Inferno.Api/Program.cs @@ -3,6 +3,7 @@ using System.Device.Spi; using Inferno.Api.Services; using Inferno.Api.Devices; +using Inferno.Api.Interfaces; using Inferno.Common.Interfaces; GpioController _gpio = new GpioController(PinNumberingScheme.Logical, new RaspberryPi3Driver()); @@ -20,11 +21,28 @@ builder.Services.AddControllers(); -builder.Services.AddSingleton(new Smoker(new Auger(_gpio, 22), - new Blower(_gpio, 21), - new Igniter(_gpio, 23), - new RtdArray(_spi), - new Display())); +var smoker = new Smoker(new Auger(_gpio, 22), + new Blower(_gpio, 21), + new Igniter(_gpio, 23), + new RtdArray(_spi), + new Display()); +builder.Services.AddSingleton(smoker); + +// Cook history: a SQLite store plus a background logger that samples the smoker and +// records each cook as a session. The logger only depends on ISmoker.Status, so it +// stays decoupled from the safety-critical state machine. DB lives outside the +// publish dir (which the deploy replaces) at ~/inferno/data/inferno.db; override +// with INFERNO_DB_PATH. +string dbPath = Environment.GetEnvironmentVariable("INFERNO_DB_PATH") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "inferno", "data", "inferno.db"); +Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); +var cookLogStore = new SqliteCookLogStore(dbPath); +cookLogStore.Initialize(); +builder.Services.AddSingleton(cookLogStore); + +var cookLogger = new CookLogger(smoker, cookLogStore); +builder.Services.AddSingleton(cookLogger); var app = builder.Build(); @@ -36,7 +54,11 @@ // the shared GPIO controller is disposed afterward. app.Lifetime.ApplicationStopping.Register(() => { + // Order matters: flush + close the active cook session before the smoker tears + // down, then close the DB, then release the GPIO. + cookLogger.Dispose(); (app.Services.GetService() as IDisposable)?.Dispose(); + cookLogStore.Dispose(); _gpio.Dispose(); }); diff --git a/Inferno.Api/Services/CookLogger.cs b/Inferno.Api/Services/CookLogger.cs new file mode 100644 index 0000000..770544b --- /dev/null +++ b/Inferno.Api/Services/CookLogger.cs @@ -0,0 +1,219 @@ +using System.Diagnostics; +using Inferno.Api.Interfaces; +using Inferno.Api.Models; +using Inferno.Common.Extensions; +using Inferno.Common.Interfaces; +using Inferno.Common.Models; + +namespace Inferno.Api.Services +{ + /// + /// Records cook history by polling on a fixed cadence, + /// inferring cook-session boundaries from mode transitions (see ). + /// It deliberately does NOT hook into the safety-critical state machine: a logging + /// failure stays contained here and can never stall a mode change or fire control. + /// Mirrors the loop+Tick pattern of /FireMinder. + /// + public class CookLogger : IDisposable + { + // ~10s matches the Hold cycle; one sample per cycle is plenty of resolution + // for history graphs without churning the SD card. + static readonly TimeSpan SampleInterval = TimeSpan.FromSeconds(10); + + readonly ISmoker _smoker; + readonly ICookLogStore _store; + readonly Func _now; + readonly int _flushThreshold; + readonly List _buffer = new(); + readonly CancellationTokenSource _stopCts = new(); + readonly Task _loop; + + SmokerMode _previousMode; + long? _currentSessionId; + double _peakGrill; + double _peakProbe; + int _sampleCount; + + public CookLogger(ISmoker smoker, ICookLogStore store, Func? clock = null, + bool autoStart = true, int flushThreshold = 6) + { + _smoker = smoker; + _store = store; + _now = clock ?? (() => DateTime.Now); + _flushThreshold = flushThreshold; + + ResumeOrReset(); + + // Tests drive Tick() directly; skip the live loop. + _loop = autoStart ? LoggerLoop() : Task.CompletedTask; + } + + /// + /// On startup, reconcile with any session left open by a previous run: + /// resume it if we're still cooking (API restarted mid-cook), or close out an + /// orphan from an unclean shutdown. Otherwise start from a clean baseline so the + /// first Tick opens a session if the smoker is already cooking. + /// + void ResumeOrReset() + { + long? active = _store.GetActiveSessionId(); + if (active != null && _smoker.Mode.IsCookingMode()) + { + _currentSessionId = active; + _previousMode = _smoker.Mode; + _peakGrill = double.NaN; + _peakProbe = double.NaN; + _sampleCount = 0; + } + else + { + if (active != null) + { + _store.CloseSession(active.Value, _now(), double.NaN, double.NaN, 0); + } + _previousMode = SmokerMode.Ready; + } + } + + async Task LoggerLoop() + { + Debug.WriteLine("Starting cook logger thread."); + while (!_stopCts.IsCancellationRequested) + { + try + { + Tick(); + await Task.Delay(SampleInterval, _stopCts.Token); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Debug.WriteLine($"{_now()} Cook logger loop exception! {ex.Message}"); + } + } + } + + /// + /// One sampling iteration. Detects session boundaries from the mode transition, + /// then (if a session is open) buffers a sample and flushes on the batch threshold. + /// Extracted from the loop so tests can drive it deterministically. + /// + internal void Tick() + { + var status = _smoker.Status; + var mode = _smoker.Mode; + + // Open on first entry into a cooking mode from a non-cooking one. + if (!_previousMode.IsCookingMode() && mode.IsCookingMode() && _currentSessionId == null) + { + OpenSession(); + } + // Close once the smoker returns to Ready (after the Shutdown cooldown, whose + // samples we keep in this same session). + else if (_currentSessionId != null && mode == SmokerMode.Ready) + { + CloseSession(); + } + + _previousMode = mode; + + if (_currentSessionId == null) + { + return; + } + + _buffer.Add(new CookSample + { + Timestamp = _now(), + GrillTemp = status.Temps?.GrillTemp ?? double.NaN, + ProbeTemp = status.Temps?.ProbeTemp ?? double.NaN, + Mode = status.Mode, + SetPoint = status.SetPoint, + PValue = status.PValue, + AugerOn = status.AugerOn, + BlowerOn = status.BlowerOn, + IgniterOn = status.IgniterOn, + FireHealthy = status.FireHealthy, + Preheated = status.Preheated, + }); + _sampleCount++; + TrackPeaks(status.Temps); + + if (_buffer.Count >= _flushThreshold) + { + Flush(); + } + } + + void OpenSession() + { + _currentSessionId = _store.OpenSession(_now(), null); + _peakGrill = double.NaN; + _peakProbe = double.NaN; + _sampleCount = 0; + _buffer.Clear(); + } + + void CloseSession() + { + if (_currentSessionId == null) + { + return; + } + + Flush(); + _store.CloseSession(_currentSessionId.Value, _now(), _peakGrill, _peakProbe, _sampleCount); + _currentSessionId = null; + } + + void Flush() + { + if (_currentSessionId == null || _buffer.Count == 0) + { + return; + } + + _store.InsertSamples(_currentSessionId.Value, _buffer); + _buffer.Clear(); + } + + void TrackPeaks(Temps? temps) + { + if (temps == null) + { + return; + } + + if (double.IsNaN(_peakGrill) || temps.GrillTemp > _peakGrill) + { + _peakGrill = temps.GrillTemp; + } + if (double.IsNaN(_peakProbe) || temps.ProbeTemp > _peakProbe) + { + _peakProbe = temps.ProbeTemp; + } + } + + public void Dispose() + { + _stopCts.Cancel(); + try + { + // Flush buffered samples and close out an in-progress cook so a clean + // restart doesn't leave a dangling open session. + if (_currentSessionId != null) + { + CloseSession(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"{_now()} Cook logger dispose flush failed: {ex.Message}"); + } + _stopCts.Dispose(); + } + } +} diff --git a/Inferno.Api/Services/SqliteCookLogStore.cs b/Inferno.Api/Services/SqliteCookLogStore.cs new file mode 100644 index 0000000..f25881f --- /dev/null +++ b/Inferno.Api/Services/SqliteCookLogStore.cs @@ -0,0 +1,305 @@ +using System.Diagnostics; +using System.Globalization; +using Inferno.Api.Interfaces; +using Inferno.Api.Models; +using Microsoft.Data.Sqlite; + +namespace Inferno.Api.Services +{ + /// + /// SQLite-backed cook history store. Holds a single long-lived connection (so an + /// in-memory database survives for the process lifetime in tests, and the Pi keeps + /// one handle instead of churning file opens). WAL + synchronous=NORMAL minimize + /// SD-card fsyncs; writes are batched into transactions by the caller. A lock + /// serializes all access to the connection across the logger loop and request threads. + /// + public class SqliteCookLogStore : ICookLogStore + { + // Round-trippable, sortable, timezone-unambiguous timestamps. + const string TimeFormat = "o"; + + readonly string _connectionString; + readonly object _sync = new(); + SqliteConnection? _connection; + + public SqliteCookLogStore(string dbPath) + { + _connectionString = $"Data Source={dbPath}"; + } + + public void Initialize() + { + lock (_sync) + { + if (_connection != null) + { + return; + } + + _connection = new SqliteConnection(_connectionString); + _connection.Open(); + + Execute( + "PRAGMA journal_mode=WAL;" + + "PRAGMA synchronous=NORMAL;" + + "CREATE TABLE IF NOT EXISTS cook_session (" + + " id INTEGER PRIMARY KEY AUTOINCREMENT," + + " start_time TEXT NOT NULL," + + " end_time TEXT," + + " label TEXT," + + " peak_grill_temp REAL," + + " peak_probe_temp REAL," + + " sample_count INTEGER NOT NULL DEFAULT 0" + + ");" + + "CREATE TABLE IF NOT EXISTS sample (" + + " id INTEGER PRIMARY KEY AUTOINCREMENT," + + " session_id INTEGER NOT NULL REFERENCES cook_session(id)," + + " timestamp TEXT NOT NULL," + + " grill_temp REAL," + + " probe_temp REAL," + + " mode TEXT NOT NULL," + + " setpoint INTEGER," + + " pvalue INTEGER," + + " auger_on INTEGER NOT NULL," + + " blower_on INTEGER NOT NULL," + + " igniter_on INTEGER NOT NULL," + + " fire_healthy INTEGER NOT NULL," + + " preheated INTEGER NOT NULL" + + ");" + + "CREATE INDEX IF NOT EXISTS ix_sample_session_time ON sample(session_id, timestamp);"); + } + } + + public long OpenSession(DateTime startUtc, string? label) + { + lock (_sync) + { + using var cmd = Connection().CreateCommand(); + cmd.CommandText = + "INSERT INTO cook_session (start_time, label) VALUES ($start, $label);" + + "SELECT last_insert_rowid();"; + cmd.Parameters.AddWithValue("$start", startUtc.ToUniversalTime().ToString(TimeFormat)); + cmd.Parameters.AddWithValue("$label", (object?)label ?? DBNull.Value); + return (long)cmd.ExecuteScalar()!; + } + } + + public void CloseSession(long id, DateTime endUtc, double peakGrillTemp, double peakProbeTemp, int sampleCount) + { + lock (_sync) + { + using var cmd = Connection().CreateCommand(); + cmd.CommandText = + "UPDATE cook_session SET end_time = $end, peak_grill_temp = $pg, " + + "peak_probe_temp = $pp, sample_count = $count WHERE id = $id;"; + cmd.Parameters.AddWithValue("$end", endUtc.ToUniversalTime().ToString(TimeFormat)); + cmd.Parameters.AddWithValue("$pg", double.IsNaN(peakGrillTemp) ? (object)DBNull.Value : peakGrillTemp); + cmd.Parameters.AddWithValue("$pp", double.IsNaN(peakProbeTemp) ? (object)DBNull.Value : peakProbeTemp); + cmd.Parameters.AddWithValue("$count", sampleCount); + cmd.Parameters.AddWithValue("$id", id); + cmd.ExecuteNonQuery(); + } + } + + public void InsertSamples(long sessionId, IReadOnlyList samples) + { + if (samples.Count == 0) + { + return; + } + + lock (_sync) + { + var connection = Connection(); + using var tx = connection.BeginTransaction(); + using var cmd = connection.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = + "INSERT INTO sample (session_id, timestamp, grill_temp, probe_temp, mode, setpoint, " + + "pvalue, auger_on, blower_on, igniter_on, fire_healthy, preheated) VALUES " + + "($sid, $ts, $grill, $probe, $mode, $sp, $pv, $auger, $blower, $igniter, $fire, $preheat);"; + + var sid = cmd.Parameters.Add("$sid", SqliteType.Integer); + var ts = cmd.Parameters.Add("$ts", SqliteType.Text); + var grill = cmd.Parameters.Add("$grill", SqliteType.Real); + var probe = cmd.Parameters.Add("$probe", SqliteType.Real); + var mode = cmd.Parameters.Add("$mode", SqliteType.Text); + var sp = cmd.Parameters.Add("$sp", SqliteType.Integer); + var pv = cmd.Parameters.Add("$pv", SqliteType.Integer); + var auger = cmd.Parameters.Add("$auger", SqliteType.Integer); + var blower = cmd.Parameters.Add("$blower", SqliteType.Integer); + var igniter = cmd.Parameters.Add("$igniter", SqliteType.Integer); + var fire = cmd.Parameters.Add("$fire", SqliteType.Integer); + var preheat = cmd.Parameters.Add("$preheat", SqliteType.Integer); + + foreach (var s in samples) + { + sid.Value = sessionId; + ts.Value = s.Timestamp.ToUniversalTime().ToString(TimeFormat); + grill.Value = s.GrillTemp; + probe.Value = s.ProbeTemp; + mode.Value = s.Mode; + sp.Value = s.SetPoint; + pv.Value = s.PValue; + auger.Value = s.AugerOn ? 1 : 0; + blower.Value = s.BlowerOn ? 1 : 0; + igniter.Value = s.IgniterOn ? 1 : 0; + fire.Value = s.FireHealthy ? 1 : 0; + preheat.Value = s.Preheated ? 1 : 0; + cmd.ExecuteNonQuery(); + } + + tx.Commit(); + } + } + + public long? GetActiveSessionId() + { + lock (_sync) + { + using var cmd = Connection().CreateCommand(); + cmd.CommandText = "SELECT id FROM cook_session WHERE end_time IS NULL ORDER BY id DESC LIMIT 1;"; + var result = cmd.ExecuteScalar(); + return result is long id ? id : null; + } + } + + public void SetLabel(long id, string label) + { + lock (_sync) + { + using var cmd = Connection().CreateCommand(); + cmd.CommandText = "UPDATE cook_session SET label = $label WHERE id = $id;"; + cmd.Parameters.AddWithValue("$label", label); + cmd.Parameters.AddWithValue("$id", id); + cmd.ExecuteNonQuery(); + } + } + + public IReadOnlyList ListSessions() + { + lock (_sync) + { + using var cmd = Connection().CreateCommand(); + cmd.CommandText = + "SELECT id, start_time, end_time, label, peak_grill_temp, peak_probe_temp, sample_count " + + "FROM cook_session ORDER BY id DESC;"; + using var reader = cmd.ExecuteReader(); + var sessions = new List(); + while (reader.Read()) + { + sessions.Add(ReadSession(reader)); + } + return sessions; + } + } + + public CookSessionDto? GetSession(long id) + { + lock (_sync) + { + using var cmd = Connection().CreateCommand(); + cmd.CommandText = + "SELECT id, start_time, end_time, label, peak_grill_temp, peak_probe_temp, sample_count " + + "FROM cook_session WHERE id = $id;"; + cmd.Parameters.AddWithValue("$id", id); + using var reader = cmd.ExecuteReader(); + return reader.Read() ? ReadSession(reader) : null; + } + } + + public IReadOnlyList GetSamples(long id, DateTime? fromUtc, DateTime? toUtc) + { + lock (_sync) + { + using var cmd = Connection().CreateCommand(); + cmd.CommandText = + "SELECT timestamp, grill_temp, probe_temp, mode, setpoint, pvalue, " + + "auger_on, blower_on, igniter_on, fire_healthy, preheated FROM sample " + + "WHERE session_id = $id" + + (fromUtc != null ? " AND timestamp >= $from" : "") + + (toUtc != null ? " AND timestamp <= $to" : "") + + " ORDER BY timestamp, id;"; + cmd.Parameters.AddWithValue("$id", id); + if (fromUtc != null) + { + cmd.Parameters.AddWithValue("$from", fromUtc.Value.ToUniversalTime().ToString(TimeFormat)); + } + if (toUtc != null) + { + cmd.Parameters.AddWithValue("$to", toUtc.Value.ToUniversalTime().ToString(TimeFormat)); + } + + using var reader = cmd.ExecuteReader(); + var samples = new List(); + while (reader.Read()) + { + samples.Add(new CookSample + { + Timestamp = ParseTime(reader.GetString(0)), + GrillTemp = reader.GetDouble(1), + ProbeTemp = reader.GetDouble(2), + Mode = reader.GetString(3), + SetPoint = reader.GetInt32(4), + PValue = reader.GetInt32(5), + AugerOn = reader.GetInt32(6) != 0, + BlowerOn = reader.GetInt32(7) != 0, + IgniterOn = reader.GetInt32(8) != 0, + FireHealthy = reader.GetInt32(9) != 0, + Preheated = reader.GetInt32(10) != 0, + }); + } + return samples; + } + } + + public void Dispose() + { + lock (_sync) + { + if (_connection == null) + { + return; + } + + try + { + Execute("PRAGMA wal_checkpoint(TRUNCATE);"); + } + catch (Exception ex) + { + Debug.WriteLine($"{DateTime.Now} Cook log checkpoint failed: {ex.Message}"); + } + + _connection.Dispose(); + _connection = null; + // Release the pooled native handle so a temp-file DB can be deleted in tests. + SqliteConnection.ClearAllPools(); + } + } + + SqliteConnection Connection() => + _connection ?? throw new InvalidOperationException("Store not initialized. Call Initialize() first."); + + void Execute(string sql) + { + using var cmd = Connection().CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + static CookSessionDto ReadSession(SqliteDataReader reader) => new() + { + Id = reader.GetInt64(0), + StartTime = ParseTime(reader.GetString(1)), + EndTime = reader.IsDBNull(2) ? null : ParseTime(reader.GetString(2)), + Label = reader.IsDBNull(3) ? null : reader.GetString(3), + PeakGrillTemp = reader.IsDBNull(4) ? null : reader.GetDouble(4), + PeakProbeTemp = reader.IsDBNull(5) ? null : reader.GetDouble(5), + SampleCount = reader.GetInt32(6), + }; + + static DateTime ParseTime(string value) => + DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + } +} diff --git a/Inferno.Deploy/Program.cs b/Inferno.Deploy/Program.cs index 383e9a5..bc4baca 100644 --- a/Inferno.Deploy/Program.cs +++ b/Inferno.Deploy/Program.cs @@ -128,6 +128,19 @@ // Resolve ~ to absolute path (CopyToRemote and systemd don't expand ~) var absoluteRemotePath = remotePath.Replace("~", $"/home/{piUser}"); + // Step 0c: Ensure the cook-history data dir exists. The API writes + // ~/inferno/data/inferno.db here; it sits OUTSIDE the publish dirs the deploy + // replaces so history survives upgrades. Owned by piUser (the service runs as + // User=pi, no systemd ProtectHome/ReadWritePaths sandboxing), so it's writable + // as-is. If sandboxing is ever added, also set ReadWritePaths={dataPath}. + var dataPath = $"{absoluteRemotePath}/data"; + var ensureDataDir = new RemoteCommand("ensure-data-dir", new Pulumi.Command.Remote.CommandArgs + { + Connection = conn, + Create = $"mkdir -p {dataPath} && echo 'data dir ready'", + Triggers = new[] { targetId }, + }, new CustomResourceOptions { DependsOn = { configureSshEnv } }); + // Step 1: Publish each project, triggered by its own source hash. Chain them so // they run one at a time: every service references Inferno.Common, so parallel // `dotnet publish` runs would rebuild Common concurrently and collide on its @@ -250,7 +263,8 @@ string BuildServiceUnit(string name, string workDir, string dllPath, string user }, }, new CustomResourceOptions { - DependsOn = { copyOps[svc], setupServices }, + // ensureDataDir gates the api start so the DB's parent dir exists first. + DependsOn = { copyOps[svc], setupServices, ensureDataDir }, }); } diff --git a/Inferno.Tests/CookLoggerTests.cs b/Inferno.Tests/CookLoggerTests.cs new file mode 100644 index 0000000..c11550b --- /dev/null +++ b/Inferno.Tests/CookLoggerTests.cs @@ -0,0 +1,268 @@ +using Inferno.Api.Services; +using Inferno.Common.Interfaces; +using Inferno.Common.Models; + +namespace Inferno.Tests; + +public class CookLoggerTests +{ + private class FakeSmoker : ISmoker + { + public SmokerMode Mode { get; set; } = SmokerMode.Ready; + public int SetPoint { get; set; } = 225; + public int PValue { get; set; } = 2; + public Temps Temps { get; set; } = new Temps { GrillTemp = 70, ProbeTemp = 70 }; + public bool AugerOn { get; set; } + public bool BlowerOn { get; set; } + public bool IgniterOn { get; set; } + public bool FireHealthy { get; set; } = true; + public bool Preheated { get; set; } + + public SmokerStatus Status => new() + { + Mode = Mode.ToString(), + SetPoint = SetPoint, + PValue = PValue, + Temps = Temps, + AugerOn = AugerOn, + BlowerOn = BlowerOn, + IgniterOn = IgniterOn, + FireHealthy = FireHealthy, + Preheated = Preheated, + }; + + public bool SetMode(SmokerMode mode) + { + Mode = mode; + return true; + } + + public void SetGrill(double t) => Temps = new Temps { GrillTemp = t, ProbeTemp = Temps.ProbeTemp }; + public void SetProbe(double t) => Temps = new Temps { GrillTemp = Temps.GrillTemp, ProbeTemp = t }; + } + + static SqliteCookLogStore NewStore() + { + var store = new SqliteCookLogStore($"file:cooklogger-{Guid.NewGuid():N}?mode=memory&cache=shared"); + store.Initialize(); + return store; + } + + static CookLogger NewLogger(ISmoker smoker, SqliteCookLogStore store, int flushThreshold = 6) + => new(smoker, store, () => DateTime.UtcNow, autoStart: false, flushThreshold: flushThreshold); + + [Theory] + [InlineData(SmokerMode.Smoke)] + [InlineData(SmokerMode.Hold)] + [InlineData(SmokerMode.Sear)] + public void Tick_OpensSession_OnEntryToCookingMode(SmokerMode cookingMode) + { + var smoker = new FakeSmoker { Mode = SmokerMode.Ready }; + using var store = NewStore(); + var logger = NewLogger(smoker, store); + + Assert.Null(store.GetActiveSessionId()); + + smoker.Mode = cookingMode; + logger.Tick(); + + Assert.NotNull(store.GetActiveSessionId()); + Assert.Single(store.ListSessions()); + } + + [Fact] + public void Tick_DoesNotOpenSecondSession_OnFlipsBetweenCookingModes() + { + var smoker = new FakeSmoker { Mode = SmokerMode.Smoke }; + using var store = NewStore(); + var logger = NewLogger(smoker, store); + + logger.Tick(); // opens + smoker.Mode = SmokerMode.Hold; + logger.Tick(); + smoker.Mode = SmokerMode.Sear; + logger.Tick(); + + Assert.Single(store.ListSessions()); + Assert.NotNull(store.GetActiveSessionId()); + } + + [Fact] + public void Tick_ClosesSession_OnReturnToReady_WithSummary() + { + var smoker = new FakeSmoker { Mode = SmokerMode.Hold }; + using var store = NewStore(); + var logger = NewLogger(smoker, store, flushThreshold: 1); + + smoker.SetGrill(220); + logger.Tick(); // opens + 1 sample + smoker.SetGrill(260); // peak grill + logger.Tick(); + + smoker.Mode = SmokerMode.Shutdown; // session stays open through cooldown + logger.Tick(); + Assert.NotNull(store.GetActiveSessionId()); + + smoker.Mode = SmokerMode.Ready; // closes + logger.Tick(); + + Assert.Null(store.GetActiveSessionId()); + var session = store.ListSessions().Single(); + Assert.NotNull(session.EndTime); + Assert.Equal(260, session.PeakGrillTemp); + Assert.Equal(3, session.SampleCount); + } + + [Theory] + [InlineData(SmokerMode.Ready)] + [InlineData(SmokerMode.Shutdown)] + [InlineData(SmokerMode.Error)] + public void Tick_DoesNotOpenSession_WhenNotCooking(SmokerMode idleMode) + { + var smoker = new FakeSmoker { Mode = idleMode }; + using var store = NewStore(); + var logger = NewLogger(smoker, store); + + logger.Tick(); + logger.Tick(); + + Assert.Empty(store.ListSessions()); + Assert.Null(store.GetActiveSessionId()); + } + + [Fact] + public void Startup_OpensSession_WhenConstructedMidCook() + { + var smoker = new FakeSmoker { Mode = SmokerMode.Hold }; + using var store = NewStore(); + var logger = NewLogger(smoker, store); + + // No active session existed in the store, so the baseline is Ready and the + // first Tick detects the (Ready -> Hold) entry and opens a session. + logger.Tick(); + + Assert.NotNull(store.GetActiveSessionId()); + Assert.Single(store.ListSessions()); + } + + [Fact] + public void Startup_ResumesExistingSession_WhenRestartedMidCook() + { + using var store = NewStore(); + long existing = store.OpenSession(DateTime.UtcNow, null); + + var smoker = new FakeSmoker { Mode = SmokerMode.Hold }; + var logger = NewLogger(smoker, store); + + logger.Tick(); + + // Adopted the open session rather than opening a new one. + Assert.Equal(existing, store.GetActiveSessionId()); + Assert.Single(store.ListSessions()); + } + + [Fact] + public void Startup_ClosesOrphanSession_WhenRestartedIdle() + { + using var store = NewStore(); + long orphan = store.OpenSession(DateTime.UtcNow, null); + + var smoker = new FakeSmoker { Mode = SmokerMode.Ready }; + NewLogger(smoker, store); + + Assert.Null(store.GetActiveSessionId()); + Assert.NotNull(store.GetSession(orphan)!.EndTime); + } + + [Fact] + public void Tick_MapsStatusFields_IntoSample() + { + var smoker = new FakeSmoker + { + Mode = SmokerMode.Hold, + SetPoint = 250, + PValue = 4, + AugerOn = true, + BlowerOn = true, + IgniterOn = false, + FireHealthy = true, + Preheated = true, + Temps = new Temps { GrillTemp = 248, ProbeTemp = 165 }, + }; + using var store = NewStore(); + var logger = NewLogger(smoker, store, flushThreshold: 1); + + logger.Tick(); + + var sample = store.GetSamples(store.GetActiveSessionId()!.Value, null, null).Single(); + Assert.Equal(248, sample.GrillTemp); + Assert.Equal(165, sample.ProbeTemp); + Assert.Equal("Hold", sample.Mode); + Assert.Equal(250, sample.SetPoint); + Assert.Equal(4, sample.PValue); + Assert.True(sample.AugerOn); + Assert.True(sample.BlowerOn); + Assert.False(sample.IgniterOn); + Assert.True(sample.FireHealthy); + Assert.True(sample.Preheated); + } + + [Fact] + public void Buffer_FlushesOnlyAfterThreshold() + { + var smoker = new FakeSmoker { Mode = SmokerMode.Hold }; + using var store = NewStore(); + var logger = NewLogger(smoker, store, flushThreshold: 3); + + logger.Tick(); + logger.Tick(); + long sessionId = store.GetActiveSessionId()!.Value; + // Two samples buffered, below the threshold of 3 -> nothing persisted yet. + Assert.Empty(store.GetSamples(sessionId, null, null)); + + logger.Tick(); + // Third tick hits the threshold and flushes the batch. + Assert.Equal(3, store.GetSamples(sessionId, null, null).Count); + } + + [Fact] + public void Dispose_FlushesBuffer_AndForceClosesOpenSession() + { + var smoker = new FakeSmoker { Mode = SmokerMode.Hold }; + using var store = NewStore(); + var logger = NewLogger(smoker, store, flushThreshold: 100); + + logger.Tick(); + logger.Tick(); + long sessionId = store.GetActiveSessionId()!.Value; + Assert.Empty(store.GetSamples(sessionId, null, null)); // still buffered + + logger.Dispose(); + + Assert.Null(store.GetActiveSessionId()); // session closed + Assert.NotNull(store.GetSession(sessionId)!.EndTime); + Assert.Equal(2, store.GetSamples(sessionId, null, null).Count); // buffer flushed + } + + [Fact] + public void Tick_TracksPeakTemps_AcrossSamples() + { + var smoker = new FakeSmoker { Mode = SmokerMode.Hold }; + using var store = NewStore(); + var logger = NewLogger(smoker, store, flushThreshold: 1); + + smoker.Temps = new Temps { GrillTemp = 200, ProbeTemp = 100 }; + logger.Tick(); + smoker.Temps = new Temps { GrillTemp = 275, ProbeTemp = 150 }; // peaks + logger.Tick(); + smoker.Temps = new Temps { GrillTemp = 240, ProbeTemp = 120 }; + logger.Tick(); + + smoker.Mode = SmokerMode.Ready; + logger.Tick(); // closes, writing the summary + + var session = store.ListSessions().Single(); + Assert.Equal(275, session.PeakGrillTemp); + Assert.Equal(150, session.PeakProbeTemp); + } +} diff --git a/Inferno.Tests/SqliteCookLogStoreTests.cs b/Inferno.Tests/SqliteCookLogStoreTests.cs new file mode 100644 index 0000000..06a4cd0 --- /dev/null +++ b/Inferno.Tests/SqliteCookLogStoreTests.cs @@ -0,0 +1,137 @@ +using Inferno.Api.Models; +using Inferno.Api.Services; + +namespace Inferno.Tests; + +public class SqliteCookLogStoreTests +{ + // A fresh in-memory store. Each gets a uniquely named shared-cache memory DB so + // tests are isolated yet the data survives for the store's single connection. + static SqliteCookLogStore NewStore() + { + var store = new SqliteCookLogStore($"file:cooklog-{Guid.NewGuid():N}?mode=memory&cache=shared"); + store.Initialize(); + return store; + } + + static CookSample Sample(DateTime ts, double grill = 225, double probe = 140) => new() + { + Timestamp = ts, + GrillTemp = grill, + ProbeTemp = probe, + Mode = "Hold", + SetPoint = 225, + PValue = 2, + AugerOn = true, + BlowerOn = true, + IgniterOn = false, + FireHealthy = true, + Preheated = true, + }; + + [Fact] + public void Initialize_IsIdempotent() + { + using var store = NewStore(); + // Second Initialize on an already-open store is a no-op, not an error. + store.Initialize(); + Assert.Empty(store.ListSessions()); + } + + [Fact] + public void OpenClose_RoundTrips_AndActiveIdTracksState() + { + using var store = NewStore(); + var start = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + long id = store.OpenSession(start, "brisket"); + Assert.Equal(id, store.GetActiveSessionId()); + + store.CloseSession(id, start.AddHours(8), 250, 203, 42); + Assert.Null(store.GetActiveSessionId()); + + var session = store.GetSession(id); + Assert.NotNull(session); + Assert.Equal("brisket", session!.Label); + Assert.Equal(start, session.StartTime); + Assert.Equal(start.AddHours(8), session.EndTime); + Assert.Equal(250, session.PeakGrillTemp); + Assert.Equal(203, session.PeakProbeTemp); + Assert.Equal(42, session.SampleCount); + } + + [Fact] + public void InsertSamples_PersistsBatch_OrderedByTimestamp() + { + using var store = NewStore(); + var t = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + long id = store.OpenSession(t, null); + + // Insert out of order; GetSamples must return them sorted by timestamp. + store.InsertSamples(id, new[] + { + Sample(t.AddSeconds(20), grill: 230), + Sample(t.AddSeconds(0), grill: 210), + Sample(t.AddSeconds(10), grill: 220), + }); + + var samples = store.GetSamples(id, null, null); + Assert.Equal(3, samples.Count); + Assert.Equal(210, samples[0].GrillTemp); + Assert.Equal(220, samples[1].GrillTemp); + Assert.Equal(230, samples[2].GrillTemp); + Assert.False(samples[0].IgniterOn); + Assert.True(samples[0].AugerOn); + Assert.Equal("Hold", samples[0].Mode); + } + + [Fact] + public void GetSamples_FiltersByTimeBounds() + { + using var store = NewStore(); + var t = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + long id = store.OpenSession(t, null); + store.InsertSamples(id, new[] + { + Sample(t.AddSeconds(0)), + Sample(t.AddSeconds(10)), + Sample(t.AddSeconds(20)), + Sample(t.AddSeconds(30)), + }); + + var bounded = store.GetSamples(id, t.AddSeconds(10), t.AddSeconds(20)); + Assert.Equal(2, bounded.Count); + Assert.Equal(t.AddSeconds(10), bounded[0].Timestamp); + Assert.Equal(t.AddSeconds(20), bounded[1].Timestamp); + } + + [Fact] + public void ListSessions_ReturnsNewestFirst() + { + using var store = NewStore(); + var t = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + long first = store.OpenSession(t, "first"); + long second = store.OpenSession(t.AddHours(1), "second"); + + var sessions = store.ListSessions(); + Assert.Equal(2, sessions.Count); + Assert.Equal(second, sessions[0].Id); + Assert.Equal(first, sessions[1].Id); + } + + [Fact] + public void SetLabel_UpdatesSession() + { + using var store = NewStore(); + long id = store.OpenSession(DateTime.UtcNow, null); + store.SetLabel(id, "pork shoulder"); + Assert.Equal("pork shoulder", store.GetSession(id)!.Label); + } + + [Fact] + public void GetSession_ReturnsNull_ForUnknownId() + { + using var store = NewStore(); + Assert.Null(store.GetSession(999)); + } +}