diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 0000000..a2189f1 --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euo pipefail + +# Only run in remote (Claude Code on the web) environments +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +# Install .NET 8 SDK if not already present +if ! command -v dotnet &> /dev/null || ! dotnet --list-sdks 2>/dev/null | grep -q "^8\."; then + curl -fsSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 8.0 +fi + +export PATH="$HOME/.dotnet:$PATH" + +# Persist PATH for the session +echo "export PATH=\"\$HOME/.dotnet:\$PATH\"" >> "$CLAUDE_ENV_FILE" + +# Restore NuGet packages +dotnet restore "$CLAUDE_PROJECT_DIR/Inferno.sln" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e06b033 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..92d45e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release --verbosity normal diff --git a/.gitignore b/.gitignore index 3e759b7..358a252 100644 --- a/.gitignore +++ b/.gitignore @@ -326,5 +326,8 @@ ASALocalRun/ # NVidia Nsight GPU debugger configuration file *.nvuser -# MFractors (Xamarin productivity tool) working folder +# MFractors (Xamarin productivity tool) working folder .mfractor/ + +# Pulumi stack configs (contain environment-specific settings) +Inferno.Deploy/Pulumi.*.yaml diff --git a/Inferno.Api/Devices/Display.cs b/Inferno.Api/Devices/Display.cs index e7f24d8..4dacb56 100644 --- a/Inferno.Api/Devices/Display.cs +++ b/Inferno.Api/Devices/Display.cs @@ -77,8 +77,7 @@ private string JustifyWithSpaces(string string1, string string2, int maxChars = return $"{string1}{spaces}{string2}"; } - #region IDisposable Support - private bool disposedValue = false; // To detect redundant calls + private bool disposedValue; protected virtual void Dispose(bool disposing) { @@ -91,31 +90,14 @@ protected virtual void Dispose(bool disposing) _lcd.Dispose(); _driver.Dispose(); } - - // TODO: free unmanaged resources (unmanaged objects) and override a finalizer below. - // TODO: set large fields to null. - disposedValue = true; } } - // TODO: override a finalizer only if Dispose(bool disposing) above has code to free unmanaged resources. - // ~Display() - // { - // // Do not change this code. Put cleanup code in Dispose(bool disposing) above. - // Dispose(false); - // } - - // This code added to correctly implement the disposable pattern. public void Dispose() { - // Do not change this code. Put cleanup code in Dispose(bool disposing) above. Dispose(true); - // TODO: uncomment the following line if the finalizer is overridden above. - // GC.SuppressFinalize(this); + GC.SuppressFinalize(this); } - - - #endregion } } \ No newline at end of file diff --git a/Inferno.Api/Devices/RelayDevice.cs b/Inferno.Api/Devices/RelayDevice.cs index f6d30d2..1a5245e 100644 --- a/Inferno.Api/Devices/RelayDevice.cs +++ b/Inferno.Api/Devices/RelayDevice.cs @@ -42,8 +42,7 @@ public void Off() } - #region IDisposable Support - private bool disposedValue = false; // To detect redundant calls + private bool disposedValue; protected virtual void Dispose(bool disposing) { @@ -57,31 +56,14 @@ protected virtual void Dispose(bool disposing) _gpio.ClosePin(_pin); } } - - // TODO: free unmanaged resources (unmanaged objects) and override a finalizer below. - // TODO: set large fields to null. - disposedValue = true; } } - // TODO: override a finalizer only if Dispose(bool disposing) above has code to free unmanaged resources. - // ~RelayDevice() - // { - // // Do not change this code. Put cleanup code in Dispose(bool disposing) above. - // Dispose(false); - // } - - // This code added to correctly implement the disposable pattern. public void Dispose() { - // Do not change this code. Put cleanup code in Dispose(bool disposing) above. Dispose(true); - // TODO: uncomment the following line if the finalizer is overridden above. - // GC.SuppressFinalize(this); + GC.SuppressFinalize(this); } - - - #endregion } } \ No newline at end of file diff --git a/Inferno.Api/Devices/RtdArray.cs b/Inferno.Api/Devices/RtdArray.cs index 8888f58..1bd6ace 100644 --- a/Inferno.Api/Devices/RtdArray.cs +++ b/Inferno.Api/Devices/RtdArray.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Device.Spi; +using System.Diagnostics; using System.Threading.Tasks; using Inferno.Api.Interfaces; using System.Linq; @@ -14,6 +15,12 @@ public class RtdArray : IRtdArray, IDisposable ConcurrentQueue _grillResistances; ConcurrentQueue _probeResistances; + // Physically reasonable temperature range for a pellet grill. + // Below -20F the sensor or wiring is likely failed/shorted. + // Above 1000F something is very wrong (firepot max is ~600F). + const double MinValidTempF = -20; + const double MaxValidTempF = 1000; + Task _adcReadTask; public RtdArray(SpiDevice spi) @@ -25,9 +32,15 @@ public RtdArray(SpiDevice spi) _adcReadTask = ReadAdc(); } - public double GrillTemp => Math.Round(RtdTempFahrenheitFromResistance(_grillResistances.Average()), 0); + public double GrillTemp => GetTemp(_grillResistances); - public double ProbeTemp => Math.Round(RtdTempFahrenheitFromResistance(_probeResistances.Average()), 0); + public double ProbeTemp => GetTemp(_probeResistances); + + private static double GetTemp(ConcurrentQueue resistances) + { + if (resistances.IsEmpty) return Double.NaN; + return Math.Round(RtdTempFahrenheitFromResistance(resistances.Average()), 0); + } private async Task ReadAdc() { @@ -35,7 +48,7 @@ private async Task ReadAdc() { int grillValue; int probeValue; - + try { grillValue = _adc.Read(0); @@ -48,30 +61,39 @@ private async Task ReadAdc() continue; } - _grillResistances.Enqueue(CalculateResistanceFromAdc(grillValue)); - _probeResistances.Enqueue(CalculateResistanceFromAdc(probeValue)); - while (_grillResistances.Count > 100) - { - double temp; - _grillResistances.TryDequeue(out temp); - } - while (_probeResistances.Count > 100) - { - double temp; - _probeResistances.TryDequeue(out temp); - } + EnqueueIfValid(_grillResistances, grillValue, "Grill"); + EnqueueIfValid(_probeResistances, probeValue, "Probe"); await Task.Delay(TimeSpan.FromMilliseconds(10)); } } - static double CalculateResistanceFromAdc(double adcValue) + private static void EnqueueIfValid(ConcurrentQueue queue, int adcValue, string sensorName) + { + double resistance = CalculateResistanceFromAdc(adcValue); + double tempF = RtdTempFahrenheitFromResistance(resistance); + + if (Double.IsNaN(tempF) || Double.IsInfinity(tempF) || + tempF < MinValidTempF || tempF > MaxValidTempF) + { + Debug.WriteLine($"{sensorName} sensor: rejected reading {tempF:F1}F (ADC={adcValue}, R={resistance:F1})"); + return; + } + + queue.Enqueue(resistance); + while (queue.Count > 100) + { + queue.TryDequeue(out _); + } + } + + internal static double CalculateResistanceFromAdc(double adcValue) { double rtdV = (adcValue / 1023) * 3.3; return ((3.3 * 1000) - (rtdV * 1000)) / rtdV; } - static double RtdTempFahrenheitFromResistance(double Resistance) + internal static double RtdTempFahrenheitFromResistance(double Resistance) { double A = 3.90830e-3; // Coefficient A double B = -5.775e-7; // Coefficient B @@ -82,8 +104,7 @@ static double RtdTempFahrenheitFromResistance(double Resistance) } - #region IDisposable Support - private bool disposedValue = false; // To detect redundant calls + private bool disposedValue; protected virtual void Dispose(bool disposing) { @@ -93,26 +114,14 @@ protected virtual void Dispose(bool disposing) { _adc.Dispose(); } - disposedValue = true; } } - // TODO: override a finalizer only if Dispose(bool disposing) above has code to free unmanaged resources. - // ~TempProbes() - // { - // // Do not change this code. Put cleanup code in Dispose(bool disposing) above. - // Dispose(false); - // } - - // This code added to correctly implement the disposable pattern. public void Dispose() { - // Do not change this code. Put cleanup code in Dispose(bool disposing) above. Dispose(true); - // TODO: uncomment the following line if the finalizer is overridden above. - // GC.SuppressFinalize(this); + GC.SuppressFinalize(this); } - #endregion } } \ No newline at end of file diff --git a/Inferno.Api/Inferno.Api.csproj b/Inferno.Api/Inferno.Api.csproj index 184814f..4d32fb8 100644 --- a/Inferno.Api/Inferno.Api.csproj +++ b/Inferno.Api/Inferno.Api.csproj @@ -6,6 +6,10 @@ enable + + + + diff --git a/Inferno.Api/Pid/SmokerPid.cs b/Inferno.Api/Pid/SmokerPid.cs index cbc0611..f6a0d9e 100644 --- a/Inferno.Api/Pid/SmokerPid.cs +++ b/Inferno.Api/Pid/SmokerPid.cs @@ -27,7 +27,10 @@ public SmokerPid(double PB, double Ti, double Td) public double GetControlVariable(double currentTemp) { if (double.IsNaN(currentTemp)) + { + _lastUpdate = DateTime.Now; return 0; + } double error = currentTemp - SetPoint; @@ -38,7 +41,7 @@ public double GetControlVariable(double currentTemp) _integral = _integral.Clamp(-IntegralMax(), IntegralMax()); double I = GainI() * _integral; - double derivative = (currentTemp - _lastTemp) / dT.Seconds; + double derivative = (currentTemp - _lastTemp) / dT.TotalSeconds; double D = GainD() * derivative; double u = P + I + D; diff --git a/Inferno.Api/Services/FireMinder.cs b/Inferno.Api/Services/FireMinder.cs index 8f48150..e7f817d 100644 --- a/Inferno.Api/Services/FireMinder.cs +++ b/Inferno.Api/Services/FireMinder.cs @@ -23,6 +23,7 @@ public class FireMinder public bool IsFireHealthy => !_fireCheck; public bool IsFireStarted => _fireStarted; + public bool IsReigniting => _fireCheck && _igniter.IsOn; public FireMinder(ISmoker smoker, IRelayDevice igniter) { diff --git a/Inferno.Api/Services/Smoker.cs b/Inferno.Api/Services/Smoker.cs index 29688f3..ca7d709 100644 --- a/Inferno.Api/Services/Smoker.cs +++ b/Inferno.Api/Services/Smoker.cs @@ -74,7 +74,7 @@ public Smoker(IRelayDevice auger, _lastModeChange = DateTime.Now; PValue = 2; - CancellationTokenSource _cts = new CancellationTokenSource(); + _cts = new CancellationTokenSource(); _pid = new SmokerPid(60.0, 180.0, 45.0); @@ -230,8 +230,13 @@ private async Task ModeLoop() /// private async Task Smoke() { - _blower.On(); + if (_fireMinder.IsReigniting) + { + await ReignitionFeed(); + return; + } + _blower.On(); TimeSpan waitTime = TimeSpan.FromSeconds(45 + (10 * PValue)); await RunAuger(TimeSpan.FromSeconds(15), waitTime); @@ -246,6 +251,12 @@ private async Task Smoke() /// private async Task Hold() { + if (_fireMinder.IsReigniting) + { + await ReignitionFeed(); + return; + } + _blower.On(); if (_igniter.IsOn && !_fireMinder.IsFireStarted) @@ -328,11 +339,28 @@ private async Task RunAuger() } + /// + /// Minimal auger feed during reignition to prevent pellet accumulation in the firepot. + /// Feeds just enough to give the igniter something to light without flooding. + /// + private async Task ReignitionFeed() + { + _blower.On(); + Debug.WriteLine("Reignition feed: minimal auger pulse."); + await RunAuger(TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(57)); + } + /// /// Burn hot /// private async Task Sear() { + if (_fireMinder.IsReigniting) + { + await ReignitionFeed(); + return; + } + if (_igniter.IsOn && !_fireMinder.IsFireStarted) { Debug.WriteLine("Sear: Igniter is on during startup. Diverting to SMOKE mode."); @@ -340,6 +368,13 @@ private async Task Sear() return; } + if (_rtdArray.GrillTemp < _minSetPoint) + { + Debug.WriteLine($"Sear: Grill temp {_rtdArray.GrillTemp} below {_minSetPoint}. Diverting to SMOKE to establish fire."); + await Smoke(); + return; + } + if (_rtdArray.GrillTemp < _maxGrillTemp) { await RunAuger(); diff --git a/Inferno.Api/publish.bat b/Inferno.Api/publish.bat deleted file mode 100644 index 07eed75..0000000 --- a/Inferno.Api/publish.bat +++ /dev/null @@ -1,3 +0,0 @@ -dotnet publish -c Debug -scp -rC .\bin\Debug\net8.0\publish\* pi@inferno:~/inferno/api -ssh pi@inferno sudo reboot \ No newline at end of file diff --git a/Inferno.Cli/publish.bat b/Inferno.Cli/publish.bat deleted file mode 100644 index 731efbd..0000000 --- a/Inferno.Cli/publish.bat +++ /dev/null @@ -1,2 +0,0 @@ -dotnet publish -c Debug -scp -r .\bin\Debug\net8.0\publish\* pi@inferno:~/inferno/cli \ No newline at end of file diff --git a/Inferno.Common/Inferno.Common.csproj b/Inferno.Common/Inferno.Common.csproj index 822b287..034bb7a 100644 --- a/Inferno.Common/Inferno.Common.csproj +++ b/Inferno.Common/Inferno.Common.csproj @@ -6,8 +6,6 @@ enable - - - + diff --git a/Inferno.Common/Services/SmokerProxy.cs b/Inferno.Common/Services/SmokerProxy.cs index bec45d4..3b2ba79 100644 --- a/Inferno.Common/Services/SmokerProxy.cs +++ b/Inferno.Common/Services/SmokerProxy.cs @@ -1,7 +1,7 @@ using System.Text; using Inferno.Common.Models; -using Newtonsoft.Json; +using System.Text.Json; namespace Inferno.Common.Proxies { @@ -23,7 +23,8 @@ public SmokerProxy() public async Task GetStatusAsync() { HttpResponseMessage result = await InfernoApiRequestAsync(SmokerEndpoint.status); - return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()) ?? new SmokerStatus(); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + return JsonSerializer.Deserialize(await result.Content.ReadAsStringAsync(), options) ?? new SmokerStatus(); } public async Task SetSetPointAsync(int setPoint) @@ -73,24 +74,13 @@ protected virtual void Dispose(bool disposing) { _client.Dispose(); } - - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null disposedValue = true; } } - // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources - // ~SmokerProxy() - // { - // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - // Dispose(disposing: false); - // } - public void Dispose() { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); + Dispose(true); GC.SuppressFinalize(this); } } diff --git a/Inferno.Deploy/Inferno.Deploy.csproj b/Inferno.Deploy/Inferno.Deploy.csproj new file mode 100644 index 0000000..5ed327c --- /dev/null +++ b/Inferno.Deploy/Inferno.Deploy.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + diff --git a/Inferno.Deploy/Program.cs b/Inferno.Deploy/Program.cs new file mode 100644 index 0000000..902a433 --- /dev/null +++ b/Inferno.Deploy/Program.cs @@ -0,0 +1,156 @@ +using Pulumi; +using Pulumi.Command.Remote; +using Pulumi.Command.Remote.Inputs; +using Pulumi.Command.Local; +using LocalCommand = Pulumi.Command.Local.Command; +using RemoteCommand = Pulumi.Command.Remote.Command; + +return await Deployment.RunAsync(() => +{ + var config = new Config(); + var piHost = config.Get("piHost") ?? "inferno"; + var piUser = config.Get("piUser") ?? "pi"; + var remotePath = config.Get("remotePath") ?? "~/inferno"; + var privateKeyPath = config.Get("privateKeyPath") ?? "~/.ssh/id_rsa"; + var mqttBrokerAddress = config.Get("mqttBrokerAddress") ?? "localhost"; + var mqttUsername = config.Get("mqttUsername") ?? ""; + var mqttPassword = config.Get("mqttPassword") ?? ""; + + var expandedKeyPath = privateKeyPath.Replace("~", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + if (!File.Exists(expandedKeyPath)) + { + Pulumi.Log.Warn($"SSH private key not found at '{expandedKeyPath}'. Deployment will fail unless the key is provided."); + } + var privateKey = Output.Create(File.Exists(expandedKeyPath) ? File.ReadAllText(expandedKeyPath) : ""); + + var conn = new ConnectionArgs + { + Host = piHost, + User = piUser, + PrivateKey = privateKey, + }; + + var services = new[] { "api", "mqtt", "cli" }; + var projectMap = new Dictionary + { + ["api"] = "Inferno.Api", + ["mqtt"] = "Inferno.Mqtt", + ["cli"] = "Inferno.Cli", + }; + + // Ensure publish directories exist so FileArchive doesn't fail during preview + foreach (var svc in services) + { + Directory.CreateDirectory(Path.Combine("..", "publish", svc)); + } + + // Step 0: Ensure .NET 8 runtime is installed on the Pi + var installDotnet = new RemoteCommand("install-dotnet", new Pulumi.Command.Remote.CommandArgs + { + Connection = conn, + Create = "if ! command -v dotnet &> /dev/null || ! dotnet --list-runtimes 2>/dev/null | grep -q 'Microsoft.AspNetCore.App 8.'; then curl -fsSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 8.0 --runtime aspnetcore; fi && echo \"dotnet: $(dotnet --version 2>/dev/null || echo 'not found')\"", + }); + + // Step 1: Publish all projects locally (sequential to avoid shared-project file locking) + var publishCommands = services.Select(svc => + $"dotnet publish ../{projectMap[svc]} -c Release -o ../publish/{svc}"); + var publishAll = new LocalCommand("publish-all", new Pulumi.Command.Local.CommandArgs + { + Create = string.Join(" && ", publishCommands), + Triggers = new[] + { + // Re-publish when this stack is updated + DateTime.UtcNow.ToString("o"), + }, + }); + + // Step 2: Copy published artifacts to the Pi (Pulumi diffs the file archive) + var copyOps = new Dictionary(); + foreach (var svc in services) + { + copyOps[svc] = new CopyToRemote($"copy-{svc}", new CopyToRemoteArgs + { + Connection = conn, + Source = new FileArchive($"../publish/{svc}"), + RemotePath = $"{remotePath}/{svc}", + }, new CustomResourceOptions + { + DependsOn = { publishAll, installDotnet }, + }); + } + + // Step 4: Ensure systemd services exist and start them + // Resolve ~ to absolute path for systemd (which doesn't expand ~) + var absoluteRemotePath = remotePath.Replace("~", $"/home/{piUser}"); + + string BuildServiceUnit(string name, string workDir, string dllPath, string user, string? afterService = null, string extraEnv = "") + { + var after = afterService != null ? $"network.target {afterService}" : "network.target"; + return + "[Unit]\n" + + $"Description=Inferno {name} Service\n" + + $"After={after}\n" + + "\n" + + "[Service]\n" + + $"Environment=DOTNET_ROOT=/home/{user}/.dotnet\n" + + $"Environment=PATH=/home/{user}/.dotnet:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n" + + extraEnv + + $"WorkingDirectory={workDir}\n" + + $"ExecStart=/home/{user}/.dotnet/dotnet {dllPath}\n" + + "Restart=always\n" + + "RestartSec=5\n" + + $"User={user}\n" + + "\n" + + "[Install]\n" + + "WantedBy=multi-user.target\n"; + } + + var setupCommands = new List(); + foreach (var svc in services) + { + var project = projectMap[svc]; + var workDir = $"{absoluteRemotePath}/{svc}"; + var dllPath = $"{workDir}/{project}.dll"; + + string? afterService = null; + var extraEnv = ""; + if (svc == "mqtt") + { + afterService = "inferno-api.service"; + extraEnv = + $"Environment=MQTT_BROKER_ADDRESS={mqttBrokerAddress}\n" + + $"Environment=MQTT_USERNAME={mqttUsername}\n" + + $"Environment=MQTT_PASSWORD={mqttPassword}\n"; + } + + var unitFile = BuildServiceUnit(svc.ToUpper(), workDir, dllPath, piUser, afterService, extraEnv); + var escapedUnit = unitFile.Replace("'", "'\\''"); + setupCommands.Add($"echo '{escapedUnit}' | sudo tee /etc/systemd/system/inferno-{svc}.service > /dev/null"); + } + setupCommands.Add("sudo systemctl daemon-reload"); + setupCommands.Add("sudo systemctl stop inferno-api inferno-mqtt inferno-cli 2>/dev/null || true"); + foreach (var svc in services) + { + setupCommands.Add($"sudo systemctl enable --now inferno-{svc}"); + } + + // Trigger restart only when copied files change + var copyTriggers = copyOps.Values.Select(c => c.Urn.Apply(u => u)).ToList(); + + var startServices = new RemoteCommand("start-services", new Pulumi.Command.Remote.CommandArgs + { + Connection = conn, + Create = string.Join(" && ", setupCommands), + Triggers = copyTriggers, + }, new CustomResourceOptions + { + DependsOn = copyOps.Values.Cast().ToList(), + }); + + return new Dictionary + { + ["piHost"] = piHost, + ["remotePath"] = remotePath, + ["services"] = services, + }; +}); diff --git a/Inferno.Deploy/Pulumi.yaml b/Inferno.Deploy/Pulumi.yaml new file mode 100644 index 0000000..7c3b4ae --- /dev/null +++ b/Inferno.Deploy/Pulumi.yaml @@ -0,0 +1,27 @@ +name: inferno-deploy +runtime: + name: dotnet +description: Deploy Inferno services to Raspberry Pi via SSH +config: + piHost: + description: Hostname or IP of the Raspberry Pi + default: inferno + piUser: + description: SSH user on the Pi + default: pi + remotePath: + description: Base path on the Pi for deployed services + default: ~/inferno + privateKeyPath: + description: Path to SSH private key + default: ~/.ssh/id_rsa + mqttBrokerAddress: + description: Hostname or IP of the MQTT broker + default: localhost + mqttUsername: + description: MQTT broker username + default: "" + mqttPassword: + description: MQTT broker password + secret: true + default: "" diff --git a/Inferno.Mqtt/Services/SmokerBridge.cs b/Inferno.Mqtt/Services/SmokerBridge.cs index a0c4cda..dca8137 100644 --- a/Inferno.Mqtt/Services/SmokerBridge.cs +++ b/Inferno.Mqtt/Services/SmokerBridge.cs @@ -260,17 +260,13 @@ private void Dispose(bool disposing) { _mqttClient.Dispose(); } - - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null disposedValue = true; } } public void Dispose() { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); + Dispose(true); GC.SuppressFinalize(this); } } diff --git a/Inferno.Mqtt/publish.bat b/Inferno.Mqtt/publish.bat deleted file mode 100644 index 7cca989..0000000 --- a/Inferno.Mqtt/publish.bat +++ /dev/null @@ -1,2 +0,0 @@ -dotnet publish -c Debug -scp -r .\bin\Debug\net8.0\publish\* pi@inferno:~/inferno/mqtt \ No newline at end of file diff --git a/Inferno.Tests/ExtensionsTests.cs b/Inferno.Tests/ExtensionsTests.cs new file mode 100644 index 0000000..2e06cbb --- /dev/null +++ b/Inferno.Tests/ExtensionsTests.cs @@ -0,0 +1,39 @@ +using Inferno.Common.Extensions; +using Inferno.Common.Models; + +namespace Inferno.Tests; + +public class ExtensionsTests +{ + [Theory] + [InlineData(5.0, 0.0, 10.0, 5.0)] // Within range + [InlineData(-1.0, 0.0, 10.0, 0.0)] // Below min + [InlineData(15.0, 0.0, 10.0, 10.0)] // Above max + [InlineData(0.0, 0.0, 10.0, 0.0)] // At min + [InlineData(10.0, 0.0, 10.0, 10.0)] // At max + public void Clamp_Double_ClampsCorrectly(double value, double min, double max, double expected) + { + Assert.Equal(expected, value.Clamp(min, max)); + } + + [Theory] + [InlineData(5, 0, 10, 5)] + [InlineData(-1, 0, 10, 0)] + [InlineData(15, 0, 10, 10)] + public void Clamp_Int_ClampsCorrectly(int value, int min, int max, int expected) + { + Assert.Equal(expected, value.Clamp(min, max)); + } + + [Theory] + [InlineData(SmokerMode.Smoke, true)] + [InlineData(SmokerMode.Hold, true)] + [InlineData(SmokerMode.Sear, true)] + [InlineData(SmokerMode.Ready, false)] + [InlineData(SmokerMode.Shutdown, false)] + [InlineData(SmokerMode.Error, false)] + public void IsCookingMode_ReturnsExpected(SmokerMode mode, bool expected) + { + Assert.Equal(expected, mode.IsCookingMode()); + } +} diff --git a/Inferno.Tests/FireMinderTests.cs b/Inferno.Tests/FireMinderTests.cs new file mode 100644 index 0000000..50b4c45 --- /dev/null +++ b/Inferno.Tests/FireMinderTests.cs @@ -0,0 +1,85 @@ +using Inferno.Api.Interfaces; +using Inferno.Api.Services; +using Inferno.Common.Interfaces; +using Inferno.Common.Models; + +namespace Inferno.Tests; + +public class FireMinderTests +{ + private class FakeRelay : IRelayDevice + { + public bool IsOn { get; private set; } + public void On() => IsOn = true; + public void Off() => IsOn = false; + } + + 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 SmokerStatus Status => new SmokerStatus + { + Mode = Mode.ToString(), + SetPoint = SetPoint, + Temps = Temps + }; + + public bool SetMode(SmokerMode mode) + { + Mode = mode; + return true; + } + } + + [Fact] + public void GetFireCheckTemp_SmokeMode_Returns140() + { + var smoker = new FakeSmoker { Mode = SmokerMode.Smoke }; + var igniter = new FakeRelay(); + var fm = new FireMinder(smoker, igniter); + + Assert.Equal(140, fm.GetFireCheckTemp()); + } + + [Theory] + [InlineData(225)] + [InlineData(300)] + [InlineData(400)] + public void GetFireCheckTemp_HoldMode_ReturnsExpected(int setPoint) + { + var smoker = new FakeSmoker { Mode = SmokerMode.Hold, SetPoint = setPoint }; + var igniter = new FakeRelay(); + var fm = new FireMinder(smoker, igniter); + + int expected = setPoint - (setPoint / 180 * 30); + Assert.Equal(expected, fm.GetFireCheckTemp()); + } + + [Fact] + public void InitialState_FireNotStarted() + { + var smoker = new FakeSmoker(); + var igniter = new FakeRelay(); + var fm = new FireMinder(smoker, igniter); + + Assert.False(fm.IsFireStarted); + Assert.True(fm.IsFireHealthy); + Assert.False(fm.IsReigniting); + } + + [Fact] + public void ResetFireStatus_ClearsState() + { + var smoker = new FakeSmoker(); + var igniter = new FakeRelay(); + var fm = new FireMinder(smoker, igniter); + + fm.ResetFireStatus(); + + Assert.False(fm.IsFireStarted); + Assert.True(fm.IsFireHealthy); + } +} diff --git a/Inferno.Tests/Inferno.Tests.csproj b/Inferno.Tests/Inferno.Tests.csproj new file mode 100644 index 0000000..ebbfeb5 --- /dev/null +++ b/Inferno.Tests/Inferno.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Inferno.Tests/RtdArrayTests.cs b/Inferno.Tests/RtdArrayTests.cs new file mode 100644 index 0000000..eca9fc0 --- /dev/null +++ b/Inferno.Tests/RtdArrayTests.cs @@ -0,0 +1,58 @@ +using Inferno.Api.Devices; + +namespace Inferno.Tests; + +public class RtdArrayTests +{ + [Theory] + [InlineData(512, 998.0)] // Mid-range ADC → ~998Ω + [InlineData(1, 1022000.0)] // Near-zero ADC → very high resistance + public void CalculateResistanceFromAdc_ReturnsExpectedResistance(int adcValue, double expectedApprox) + { + double resistance = RtdArray.CalculateResistanceFromAdc(adcValue); + + Assert.True(resistance > 0, $"Resistance should be positive, got {resistance}"); + Assert.Equal(expectedApprox, resistance, precision: 0); + } + + [Fact] + public void CalculateResistanceFromAdc_Zero_ReturnsInfinity() + { + double resistance = RtdArray.CalculateResistanceFromAdc(0); + Assert.True(Double.IsInfinity(resistance)); + } + + [Fact] + public void RtdTempFahrenheit_KnownResistance_ReturnsExpectedTemp() + { + // PT100 with 1000Ω reference: 1000Ω at 0°C = 32°F + double tempF = RtdArray.RtdTempFahrenheitFromResistance(1000); + Assert.Equal(32, tempF, precision: 0); + } + + [Fact] + public void RtdTempFahrenheit_HigherResistance_ReturnsHigherTemp() + { + double tempLow = RtdArray.RtdTempFahrenheitFromResistance(1000); + double tempHigh = RtdArray.RtdTempFahrenheitFromResistance(1100); + Assert.True(tempHigh > tempLow); + } + + [Fact] + public void RtdTempFahrenheit_TypicalGrillTemp() + { + // ~1385Ω should be in the 200-250°F range for this RTD configuration + double tempF = RtdArray.RtdTempFahrenheitFromResistance(1385); + Assert.InRange(tempF, 200, 250); + } + + [Theory] + [InlineData(0)] // Division by zero → Infinity resistance + [InlineData(1023)] // Max ADC → near-zero resistance + public void AdcToTemp_BoundaryValues_DoNotCrash(int adcValue) + { + double resistance = RtdArray.CalculateResistanceFromAdc(adcValue); + double tempF = RtdArray.RtdTempFahrenheitFromResistance(resistance); + // Should not throw — result may be NaN/Infinity, handled by validation layer + } +} diff --git a/Inferno.Tests/SmokerPidTests.cs b/Inferno.Tests/SmokerPidTests.cs new file mode 100644 index 0000000..1949987 --- /dev/null +++ b/Inferno.Tests/SmokerPidTests.cs @@ -0,0 +1,78 @@ +using Inferno.Api.Pid; + +namespace Inferno.Tests; + +public class SmokerPidTests +{ + [Fact] + public void GetControlVariable_BelowSetPoint_ReturnsPositive() + { + var pid = new SmokerPid(60.0, 180.0, 45.0); + pid.SetPoint = 225; + pid.GetControlVariable(225); + Thread.Sleep(100); + + double u = pid.GetControlVariable(200); + Assert.True(u > 0, $"Expected positive control variable when below setpoint, got {u}"); + } + + [Fact] + public void GetControlVariable_AboveSetPoint_ReturnsNegative() + { + var pid = new SmokerPid(60.0, 180.0, 45.0); + pid.SetPoint = 225; + pid.GetControlVariable(225); + Thread.Sleep(100); + + double u = pid.GetControlVariable(250); + Assert.True(u < 0, $"Expected negative control variable when above setpoint, got {u}"); + } + + [Fact] + public void GetControlVariable_AtSetPoint_ReturnsNearZero() + { + var pid = new SmokerPid(60.0, 180.0, 45.0); + pid.SetPoint = 225; + pid.GetControlVariable(225); + Thread.Sleep(100); + + double u = pid.GetControlVariable(225); + Assert.InRange(u, -0.1, 0.1); + } + + [Fact] + public void GetControlVariable_NaN_ReturnsZero() + { + var pid = new SmokerPid(60.0, 180.0, 45.0); + pid.SetPoint = 225; + double u = pid.GetControlVariable(double.NaN); + Assert.Equal(0, u); + } + + [Fact] + public void GetControlVariable_NaN_DoesNotCorruptState() + { + var pid = new SmokerPid(60.0, 180.0, 45.0); + pid.SetPoint = 225; + pid.GetControlVariable(225); + Thread.Sleep(100); + + // Inject NaN — should not corrupt internal state + pid.GetControlVariable(double.NaN); + Thread.Sleep(100); + + // Next valid call should still behave reasonably + double u = pid.GetControlVariable(200); + Assert.True(u > 0, $"Expected positive control variable after NaN recovery, got {u}"); + Assert.False(double.IsNaN(u), "Control variable should not be NaN after NaN recovery"); + Assert.False(double.IsInfinity(u), "Control variable should not be Infinity after NaN recovery"); + } + + [Fact] + public void SetPoint_CanBeUpdated() + { + var pid = new SmokerPid(60.0, 180.0, 45.0); + pid.SetPoint = 300; + Assert.Equal(300, pid.SetPoint); + } +} diff --git a/Inferno.sln b/Inferno.sln index 87331c6..c65248f 100644 --- a/Inferno.sln +++ b/Inferno.sln @@ -11,6 +11,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Inferno.Common", "Inferno.C EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Inferno.Mqtt", "Inferno.Mqtt\Inferno.Mqtt.csproj", "{5A5F6FD4-2770-4EB8-9E69-FD95E53CF06A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inferno.Tests", "Inferno.Tests\Inferno.Tests.csproj", "{2DCD58DB-2341-4B4E-BA8F-381F8E58A507}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inferno.Deploy", "Inferno.Deploy\Inferno.Deploy.csproj", "{87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +73,30 @@ Global {5A5F6FD4-2770-4EB8-9E69-FD95E53CF06A}.Release|x64.Build.0 = Release|Any CPU {5A5F6FD4-2770-4EB8-9E69-FD95E53CF06A}.Release|x86.ActiveCfg = Release|Any CPU {5A5F6FD4-2770-4EB8-9E69-FD95E53CF06A}.Release|x86.Build.0 = Release|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Debug|x64.ActiveCfg = Debug|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Debug|x64.Build.0 = Debug|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Debug|x86.ActiveCfg = Debug|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Debug|x86.Build.0 = Debug|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Release|Any CPU.Build.0 = Release|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Release|x64.ActiveCfg = Release|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Release|x64.Build.0 = Release|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Release|x86.ActiveCfg = Release|Any CPU + {2DCD58DB-2341-4B4E-BA8F-381F8E58A507}.Release|x86.Build.0 = Release|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Debug|x64.ActiveCfg = Debug|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Debug|x64.Build.0 = Debug|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Debug|x86.ActiveCfg = Debug|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Debug|x86.Build.0 = Debug|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Release|Any CPU.Build.0 = Release|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Release|x64.ActiveCfg = Release|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Release|x64.Build.0 = Release|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Release|x86.ActiveCfg = Release|Any CPU + {87C2BE42-582C-40AD-B9B3-B7EBA7BB2EB8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/publish-all.bat b/publish-all.bat deleted file mode 100644 index ad4aaa5..0000000 --- a/publish-all.bat +++ /dev/null @@ -1,7 +0,0 @@ -pushd .\Inferno.Cli -call .\publish.bat -cd ..\Inferno.Mqtt -call .\publish.bat -cd ..\Inferno.Api -call .\publish.bat -popd \ No newline at end of file