From 51f73364ef217b90751f83f8d14fad219522025a Mon Sep 17 00:00:00 2001 From: Kaan Orbay Date: Tue, 1 Sep 2026 23:52:05 +0300 Subject: [PATCH 1/2] Release SimForge 0.7.0 --- .github/workflows/build.yml | 6 +- CHANGELOG.md | 27 + README.md | 39 +- .../ArduinoSketchProgramTests.cs | 241 +++++- SimForge.Core.Tests/CircuitAssistantTests.cs | 126 ++++ SimForge.Core.Tests/GraphTests.cs | 14 + SimForge.Core.Tests/SensorSimulationTests.cs | 59 ++ SimForge.Core/ArduinoSketchProgram.cs | 710 +++++++++++++++++- SimForge.Core/CircuitAssistant.cs | 216 ++++++ SimForge.Core/Node.cs | 10 + SimForge.Core/SensorSimulation.cs | 112 +++ SimForge/App.axaml.cs | 29 +- SimForge/MainWindow.axaml | 32 +- SimForge/MainWindow.axaml.cs | 571 ++++++++++++-- SimForge/Program.cs | 3 - SimForge/SimForge.csproj | 10 +- scripts/package-macos.sh | 2 +- 17 files changed, 2058 insertions(+), 149 deletions(-) create mode 100644 SimForge.Core.Tests/CircuitAssistantTests.cs create mode 100644 SimForge.Core.Tests/SensorSimulationTests.cs create mode 100644 SimForge.Core/CircuitAssistant.cs create mode 100644 SimForge.Core/SensorSimulation.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 37f8c36..fdd0335 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,15 +21,15 @@ jobs: - name: Windows x64 os: windows-latest rid: win-x64 - artifact: SimForge-0.4.0-windows-x64 + artifact: SimForge-0.7.0-windows-x64 - name: Linux x64 os: ubuntu-latest rid: linux-x64 - artifact: SimForge-0.4.0-linux-x64 + artifact: SimForge-0.7.0-linux-x64 - name: macOS arm64 os: macos-14 rid: osx-arm64 - artifact: SimForge-0.4.0-macos-arm64 + artifact: SimForge-0.7.0-macos-arm64 steps: - name: Check out repository diff --git a/CHANGELOG.md b/CHANGELOG.md index aa74b73..264521e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.7.0 - 2026-09-01 + +- Added a live Circuit Assistant designed for people who do not know C++ yet. +- Added ordered, actionable diagnostics for missing controllers, sensor power, signal wiring, empty sketch pins, unused outputs, incomplete LED loops, and unsafe LED paths. +- Added beginner-friendly explanations for common sketch diagnostics. +- Added complete starter sketches for analog sensors, HC-SR04, and DHT11 circuits. +- Increased regression coverage to 51 tests. + +## 0.6.0 - 2026-09-01 + +- Connected live analog, pulse, distance, temperature, and humidity readings to simple Arduino `if/else` output rules. +- Added common sensor-variable and HC-SR04 distance-conversion recognition. +- Added D7 as a bidirectional controller pin and made automatic wiring follow the sketch's referenced pins. +- Added explicit diagnostics for sensor conditions that are too complex to simulate reliably. +- Added live sensor-to-output reaction feedback and clearer powered/wired sensor status. +- Increased regression coverage to 41 tests. + +## 0.5.0 - 2026-08-31 + +- Expanded Arduino-style C++ analysis with constants, macros, built-in pin aliases, numeric logic levels, and input-only sketches. +- Added independent HIGH/LOW timing profiles so asymmetric blink sketches run correctly. +- Added A0 analog input paths and controller-ground references for practical sensor wiring. +- Replaced threshold-only sensor behavior with LDR divider, potentiometer ADC, HC-SR04 echo-time, and DHT11 sampling models. +- Allowed complete powered sensor circuits to run without requiring an LED path. +- Removed an incompatible debug-tools dependency that prevented desktop debug builds. +- Increased regression coverage from 21 to 36 tests. + ## 0.4.0 - 2026-08-25 - Hardened Arduino sketch analysis so comments and string literals are ignored. diff --git a/README.md b/README.md index 922a168..7a30fd3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SimForge Circuit Studio -SimForge is a modern desktop workspace for building, inspecting, and simulating electronic circuits. Version 0.4.0 includes an English-first interface, a searchable component library, pin-aware wiring, safer Arduino-style sketch analysis, circuit safety checks, and a polished engineering canvas. +SimForge is a modern desktop workspace for building, inspecting, and simulating electronic circuits. Version 0.7.0 adds a beginner-focused Circuit Assistant that explains missing wiring, unsafe LED paths, pin mismatches, and C++ sketch problems with actionable fixes. ## Highlights @@ -8,7 +8,11 @@ SimForge is a modern desktop workspace for building, inspecting, and simulating - Visible, type-aware pins and guided wire creation - Contextual inspector for component properties - Arduino-style `pinMode` and `digitalWrite` sketch parsing +- Arduino constants, `#define`, `LED_BUILTIN`, `analogRead`, `digitalRead`, and `pulseIn` recognition +- Live sensor-driven `if/else` output rules, including common HC-SR04 distance conversion +- Non-linear LDR response, analog ADC values, HC-SR04 echo timing, and DHT11 sampling limits - Live circuit status, topology validation, and short-circuit protection +- Live Circuit Assistant with ordered wiring fixes and complete beginner C++ examples - Keyboard-accessible controls and clear simulation feedback - Native desktop builds for Windows, Linux, and macOS @@ -31,25 +35,50 @@ Run the desktop application: dotnet run --project SimForge/SimForge.csproj ``` +Verify that the native UI can initialize and open its main window: + +```bash +dotnet run --project SimForge/SimForge.csproj -- --smoke-test +``` + +## Sensor-driven sketches + +SimForge runs a deterministic Arduino-style subset rather than invoking a native compiler. A sensor read can be used directly or assigned to a variable before a simple `if/else`: + +```cpp +void setup() { pinMode(13, OUTPUT); } + +void loop() { + int light = analogRead(A0); + if (light > 600) { + digitalWrite(13, HIGH); + } else { + digitalWrite(13, LOW); + } +} +``` + +Equivalent `pulseIn`, HC-SR04 distance, and DHT11 temperature conditions are supported. The editor reports a diagnostic instead of silently approximating unsupported complex expressions. + ## Create platform builds Each command creates a self-contained application that does not require a separate .NET installation on the target computer. ```bash # Windows x64 -dotnet publish SimForge/SimForge.csproj -c Release -r win-x64 --self-contained true -o artifacts/SimForge-0.4.0-windows-x64 +dotnet publish SimForge/SimForge.csproj -c Release -r win-x64 --self-contained true -o artifacts/SimForge-0.7.0-windows-x64 # Linux x64 -dotnet publish SimForge/SimForge.csproj -c Release -r linux-x64 --self-contained true -o artifacts/SimForge-0.4.0-linux-x64 +dotnet publish SimForge/SimForge.csproj -c Release -r linux-x64 --self-contained true -o artifacts/SimForge-0.7.0-linux-x64 # macOS Apple Silicon -dotnet publish SimForge/SimForge.csproj -c Release -r osx-arm64 --self-contained true -o artifacts/SimForge-0.4.0-macos-arm64 +dotnet publish SimForge/SimForge.csproj -c Release -r osx-arm64 --self-contained true -o artifacts/SimForge-0.7.0-macos-arm64 ``` To create a signed macOS application bundle and upload-ready ZIP: ```bash -./scripts/package-macos.sh 0.4.0 +./scripts/package-macos.sh 0.7.0 ``` On Linux, mark the executable as runnable if the archive tool does not preserve permissions: diff --git a/SimForge.Core.Tests/ArduinoSketchProgramTests.cs b/SimForge.Core.Tests/ArduinoSketchProgramTests.cs index 8e014d8..da46e4c 100644 --- a/SimForge.Core.Tests/ArduinoSketchProgramTests.cs +++ b/SimForge.Core.Tests/ArduinoSketchProgramTests.cs @@ -44,7 +44,7 @@ void loop() { } [Fact] - public void Analyze_RejectsSymbolicOutputPinWithClearDiagnostic() + public void Analyze_RecognizesBuiltInLedSymbol() { const string sketch = """ void setup() { } @@ -53,8 +53,8 @@ void setup() { } var program = ArduinoSketchProgram.Analyze(sketch); - Assert.False(program.IsValid); - Assert.Equal("Use a numeric output pin", program.Diagnostic); + Assert.True(program.IsValid); + Assert.Equal(DigitalOutputMode.High, program.Outputs[13]); } [Fact] @@ -63,7 +63,7 @@ public void Analyze_RejectsUnbalancedBraces() var program = ArduinoSketchProgram.Analyze("void setup() { } void loop() { digitalWrite(13, HIGH);"); Assert.False(program.IsValid); - Assert.Equal("Check braces", program.Diagnostic); + Assert.Equal("Check brackets and braces", program.Diagnostic); } [Fact] @@ -112,6 +112,237 @@ void setup() { } Assert.Null(exception); Assert.False(program.IsValid); - Assert.Equal("Pin number is too large", program.Diagnostic); + Assert.Equal("Pin number must be between 0 and 255", program.Diagnostic); + } + + [Fact] + public void Analyze_ResolvesDefinesConstantsAndNumericLevels() + { + const string sketch = """ + #define STATUS_PIN 12 + const unsigned long waitMs = 250; + void setup() { pinMode(STATUS_PIN, OUTPUT); } + void loop() { + digitalWrite(STATUS_PIN, 1); + delay(waitMs); + digitalWrite(STATUS_PIN, 0); + delay(waitMs); + } + """; + + var program = ArduinoSketchProgram.Analyze(sketch); + + Assert.True(program.IsValid); + Assert.Equal(DigitalOutputMode.Blink, program.Outputs[12]); + Assert.Equal(0.25, program.OutputProfiles[12].HighDurationSeconds); + Assert.Equal(0.25, program.OutputProfiles[12].LowDurationSeconds); + } + + [Fact] + public void Analyze_PreservesAsymmetricBlinkTiming() + { + const string sketch = """ + constexpr byte LED_PIN = D13; + void setup() { pinMode(LED_PIN, OUTPUT); } + void loop() { + digitalWrite(LED_PIN, HIGH); + delay(200); + digitalWrite(LED_PIN, LOW); + delay(800); + } + """; + + var program = ArduinoSketchProgram.Analyze(sketch); + + Assert.True(program.IsValid); + var profile = program.OutputProfiles[13]; + Assert.True(profile.InitialState); + Assert.Equal(0.2, profile.HighDurationSeconds); + Assert.Equal(0.8, profile.LowDurationSeconds); + } + + [Fact] + public void Analyze_AllowsInputOnlySensorSketch() + { + const string sketch = """ + void setup() { Serial.begin(9600); } + void loop() { + Serial.println(analogRead(A0)); + delay(500); + } + """; + + var program = ArduinoSketchProgram.Analyze(sketch); + + Assert.True(program.IsValid); + Assert.Equal("Input sketch ready", program.Diagnostic); + Assert.Equal("A0", Assert.Single(program.InputPins)); + Assert.Empty(program.Outputs); + } + + [Fact] + public void Analyze_RejectsWriteToExplicitInputPin() + { + const string sketch = """ + void setup() { pinMode(7, INPUT_PULLUP); } + void loop() { digitalWrite(7, HIGH); } + """; + + var program = ArduinoSketchProgram.Analyze(sketch); + + Assert.False(program.IsValid); + Assert.Equal("Pin D7 is not OUTPUT", program.Diagnostic); + } + + [Fact] + public void Analyze_RejectsFunctionPrototypesWithoutBodies() + { + var program = ArduinoSketchProgram.Analyze("void setup(); void loop(); digitalWrite(13, HIGH);"); + + Assert.False(program.IsValid); + Assert.Equal("Missing setup()", program.Diagnostic); + } + + [Fact] + public void Analyze_RecognizesCommonDhtLibraryReads() + { + const string sketch = """ + #include + void setup() { Serial.begin(9600); } + void loop() { + float temperature = dht.readTemperature(); + Serial.println(temperature); + delay(2000); + } + """; + + var program = ArduinoSketchProgram.Analyze(sketch); + + Assert.True(program.IsValid); + Assert.Equal("D2", Assert.Single(program.InputPins)); + } + + [Fact] + public void Analyze_ParsesInlineAnalogReadCondition() + { + const string sketch = """ + void setup() { pinMode(13, OUTPUT); } + void loop() { + if (analogRead(A0) > 600) { + digitalWrite(13, HIGH); + } else { + digitalWrite(13, LOW); + } + } + """; + + var program = ArduinoSketchProgram.Analyze(sketch); + + Assert.True(program.IsValid); + Assert.Equal(DigitalOutputMode.Conditional, program.Outputs[13]); + var rule = Assert.Single(program.ConditionalOutputs); + Assert.Equal("A0", rule.Condition.Pin); + Assert.Equal(ArduinoInputKind.Analog, rule.Condition.Kind); + Assert.False(rule.Evaluate(600)); + Assert.True(rule.Evaluate(601)); + } + + [Fact] + public void Analyze_ParsesSensorVariableAndConstantThreshold() + { + const string sketch = """ + const int echoPin = 2; + const int alertPin = 13; + const int nearEchoUs = 1200; + void setup() { pinMode(alertPin, OUTPUT); } + void loop() { + long echoTime = pulseIn(echoPin, HIGH); + if (echoTime <= nearEchoUs) + digitalWrite(alertPin, HIGH); + else + digitalWrite(alertPin, LOW); + } + """; + + var program = ArduinoSketchProgram.Analyze(sketch); + + Assert.True(program.IsValid); + var rule = Assert.Single(program.ConditionalOutputs); + Assert.Equal(ArduinoInputKind.PulseDurationMicroseconds, rule.Condition.Kind); + Assert.Equal("D2", rule.Condition.Pin); + Assert.True(rule.Evaluate(1200)); + Assert.False(rule.Evaluate(1201)); + } + + [Fact] + public void Analyze_UsesConfiguredPinForDhtConditional() + { + const string sketch = """ + #define DHT_PIN 7 + DHT dht(DHT_PIN, DHT11); + void setup() { pinMode(13, OUTPUT); } + void loop() { + float temperature = dht.readTemperature(); + if (temperature >= 30) { + digitalWrite(13, HIGH); + } else { + digitalWrite(13, LOW); + } + } + """; + + var program = ArduinoSketchProgram.Analyze(sketch); + + Assert.True(program.IsValid); + var rule = Assert.Single(program.ConditionalOutputs); + Assert.Equal(ArduinoInputKind.TemperatureCelsius, rule.Condition.Kind); + Assert.Equal("D7", rule.Condition.Pin); + Assert.True(rule.Evaluate(30)); + } + + [Fact] + public void Analyze_RejectsSensorConditionThatCannotBeSimulatedReliably() + { + const string sketch = """ + void setup() { pinMode(13, OUTPUT); } + void loop() { + int light = analogRead(A0); + if ((light * 2) > 600) { + digitalWrite(13, HIGH); + } else { + digitalWrite(13, LOW); + } + } + """; + + var program = ArduinoSketchProgram.Analyze(sketch); + + Assert.False(program.IsValid); + Assert.Equal("Simplify the sensor if/else", program.Diagnostic); + } + + [Fact] + public void Analyze_RecognizesCommonHcSr04DistanceConversion() + { + const string sketch = """ + void setup() { pinMode(13, OUTPUT); } + void loop() { + long duration = pulseIn(2, HIGH); + float distance = duration * 0.0343 / 2; + if (distance < 20) { + digitalWrite(13, HIGH); + } else { + digitalWrite(13, LOW); + } + } + """; + + var program = ArduinoSketchProgram.Analyze(sketch); + + Assert.True(program.IsValid); + var rule = Assert.Single(program.ConditionalOutputs); + Assert.Equal(ArduinoInputKind.DistanceCentimeters, rule.Condition.Kind); + Assert.True(rule.Evaluate(19.9)); + Assert.False(rule.Evaluate(20)); } } diff --git a/SimForge.Core.Tests/CircuitAssistantTests.cs b/SimForge.Core.Tests/CircuitAssistantTests.cs new file mode 100644 index 0000000..01ab700 --- /dev/null +++ b/SimForge.Core.Tests/CircuitAssistantTests.cs @@ -0,0 +1,126 @@ +using SimForge.Core; +using Xunit; + +namespace SimForge.Core.Tests; + +public sealed class CircuitAssistantTests +{ + [Fact] + public void Analyze_EmptyWorkspaceExplainsTheFirstStep() + { + var report = CircuitAssistant.Analyze(Input( + componentCount: 0, + hasController: false, + missingInputPins: ["A0"], + missingOutputPins: [13])); + + Assert.Equal("START", report.Status); + Assert.Equal("Start with the controller", Assert.Single(report.Issues).Title); + } + + [Fact] + public void Analyze_UnsafeLedIsBlockingAndActionable() + { + var report = CircuitAssistant.Analyze(Input(hasUnsafeLedPath: true, incompleteLedCount: 1)); + + Assert.Equal("UNSAFE", report.Status); + var issue = Assert.Single(report.Issues, item => item.Title == "LED path is unsafe"); + Assert.Contains("resistor", issue.Instruction, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Analyze_ReportsPowerSignalAndSketchPinGaps() + { + var report = CircuitAssistant.Analyze(Input( + unpoweredSensors: ["LDR Sensor"], + unwiredSensors: ["DHT11 Temperature"], + missingInputPins: ["A0"], + missingOutputPins: [13])); + + Assert.Equal("CHECK", report.Status); + Assert.Contains(report.Issues, issue => issue.Title == "Sensor power is incomplete"); + Assert.Contains(report.Issues, issue => issue.Instruction.Contains("A0", StringComparison.Ordinal)); + Assert.Contains(report.Issues, issue => issue.Instruction.Contains("D13", StringComparison.Ordinal)); + } + + [Fact] + public void Analyze_InvalidSketchProvidesBeginnerExample() + { + var report = CircuitAssistant.Analyze(Input( + sketchValid: false, + sketchDiagnostic: "Missing setup()")); + + Assert.Equal("FIX", report.Status); + Assert.Contains("void setup()", report.CodeExample); + Assert.Contains("pinMode", Assert.Single(report.Issues, issue => issue.Title == "Sketch needs attention").Instruction); + } + + [Fact] + public void Analyze_ReadySensorCircuitProvidesRelevantCode() + { + var report = CircuitAssistant.Analyze(Input( + hasActuator: true, + hasFunctionalCircuit: true, + preferredSensorName: "HC-SR04 Distance")); + + Assert.Equal("READY", report.Status); + Assert.Empty(report.Issues); + Assert.Contains("pulseIn", report.CodeExample); + Assert.Contains("distance", report.CodeExample); + } + + [Fact] + public void Analyze_ComplexConditionExplainsSupportedShape() + { + var report = CircuitAssistant.Analyze(Input( + sketchValid: false, + sketchDiagnostic: "Simplify the sensor if/else")); + + var issue = Assert.Single(report.Issues, item => item.Title == "Sketch needs attention"); + Assert.Contains("one sensor comparison", issue.Instruction); + } + + [Theory] + [InlineData("LDR Sensor")] + [InlineData("Potentiometer")] + [InlineData("HC-SR04 Distance")] + [InlineData("DHT11 Temperature")] + public void Analyze_BeginnerSensorExampleIsAcceptedBySketchAnalyzer(string sensorName) + { + var report = CircuitAssistant.Analyze(Input(preferredSensorName: sensorName)); + + Assert.NotNull(report.CodeExample); + var sketch = ArduinoSketchProgram.Analyze(report.CodeExample); + Assert.True(sketch.IsValid, sketch.Diagnostic); + Assert.NotEmpty(sketch.InputPins); + Assert.NotEmpty(sketch.ConditionalOutputs); + } + + private static CircuitAssistantInput Input( + int componentCount = 3, + bool hasController = true, + bool hasActuator = true, + bool hasFunctionalCircuit = true, + bool hasUnsafeLedPath = false, + int incompleteLedCount = 0, + IReadOnlyList? unpoweredSensors = null, + IReadOnlyList? unwiredSensors = null, + IReadOnlyList? missingInputPins = null, + IReadOnlyList? missingOutputPins = null, + bool sketchValid = true, + string sketchDiagnostic = "Sketch ready", + string? preferredSensorName = null) => new( + componentCount, + hasController, + hasActuator, + hasFunctionalCircuit, + hasUnsafeLedPath, + incompleteLedCount, + unpoweredSensors ?? [], + unwiredSensors ?? [], + missingInputPins ?? [], + missingOutputPins ?? [], + sketchValid, + sketchDiagnostic, + preferredSensorName); +} diff --git a/SimForge.Core.Tests/GraphTests.cs b/SimForge.Core.Tests/GraphTests.cs index 13c38c5..f845290 100644 --- a/SimForge.Core.Tests/GraphTests.cs +++ b/SimForge.Core.Tests/GraphTests.cs @@ -34,6 +34,20 @@ public void Connect_RejectsAnalogSensorOutputToDigitalControllerInput() graph.Connect(sensor.GetPin("OUT"), controller.GetPin("D2"))); } + [Fact] + public void Connect_AllowsAnalogSensorOutputToControllerAnalogInput() + { + var graph = new Graph(); + var controller = new MicrocontrollerNode("Controller"); + var sensor = new SensorNode("Sensor"); + graph.AddNode(controller); + graph.AddNode(sensor); + + graph.Connect(sensor.GetPin("OUT"), controller.GetPin("A0")); + + Assert.Single(graph.Connections); + } + [Fact] public void Connect_AllowsDigitalOutputToDigitalInput() { diff --git a/SimForge.Core.Tests/SensorSimulationTests.cs b/SimForge.Core.Tests/SensorSimulationTests.cs new file mode 100644 index 0000000..5ccb2a6 --- /dev/null +++ b/SimForge.Core.Tests/SensorSimulationTests.cs @@ -0,0 +1,59 @@ +using SimForge.Core; +using Xunit; + +namespace SimForge.Core.Tests; + +public sealed class SensorSimulationTests +{ + [Fact] + public void Photoresistor_UsesNonLinearVoltageDividerResponse() + { + var dark = SensorSimulation.ReadPhotoresistor(0); + var middle = SensorSimulation.ReadPhotoresistor(50); + var bright = SensorSimulation.ReadPhotoresistor(100); + + Assert.True(dark.Voltage < middle.Voltage); + Assert.True(middle.Voltage < bright.Voltage); + Assert.NotEqual((dark.AdcValue + bright.AdcValue) / 2, middle.AdcValue); + Assert.InRange(bright.AdcValue, 900, 950); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(50, 512)] + [InlineData(100, 1023)] + public void Potentiometer_MapsPositionToTenBitAdc(double position, int expectedAdc) + { + var reading = SensorSimulation.ReadPotentiometer(position); + + Assert.Equal(expectedAdc, reading.AdcValue); + } + + [Fact] + public void HcSr04_UsesTemperatureAdjustedRoundTripTime() + { + var reading = SensorSimulation.ReadHcSr04(100, 20); + + Assert.True(reading.IsInRange); + Assert.InRange(reading.EchoDurationMicroseconds, 5_800, 5_850); + Assert.InRange(SensorSimulation.DistanceFromEchoMicroseconds(reading.EchoDurationMicroseconds, 20), 99.99, 100.01); + } + + [Theory] + [InlineData(1)] + [InlineData(401)] + public void HcSr04_MarksBlindZoneAndOutOfRangeTargets(double distance) + { + Assert.False(SensorSimulation.ReadHcSr04(distance).IsInRange); + } + + [Fact] + public void Dht11_ClampsAndQuantizesToDeviceResolution() + { + var reading = SensorSimulation.ReadDht11(30.6, 95); + + Assert.Equal(31, reading.TemperatureCelsius); + Assert.Equal(90, reading.HumidityPercent); + Assert.Equal(2, reading.MinimumSampleIntervalSeconds); + } +} diff --git a/SimForge.Core/ArduinoSketchProgram.cs b/SimForge.Core/ArduinoSketchProgram.cs index 5c7d349..b7c2845 100644 --- a/SimForge.Core/ArduinoSketchProgram.cs +++ b/SimForge.Core/ArduinoSketchProgram.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Globalization; using System.Text.RegularExpressions; namespace SimForge.Core; @@ -7,59 +8,161 @@ public enum DigitalOutputMode { Blink, High, - Low + Low, + Conditional } +public enum ArduinoInputKind +{ + Analog, + Digital, + PulseDurationMicroseconds, + DistanceCentimeters, + TemperatureCelsius, + RelativeHumidity +} + +public enum ArduinoComparisonOperator +{ + LessThan, + LessThanOrEqual, + Equal, + NotEqual, + GreaterThanOrEqual, + GreaterThan +} + +public sealed record ArduinoInputCondition( + ArduinoInputKind Kind, + string Pin, + ArduinoComparisonOperator Operator, + double Threshold) +{ + public bool Evaluate(double inputValue) => Operator switch + { + ArduinoComparisonOperator.LessThan => inputValue < Threshold, + ArduinoComparisonOperator.LessThanOrEqual => inputValue <= Threshold, + ArduinoComparisonOperator.Equal => Math.Abs(inputValue - Threshold) < 0.000001, + ArduinoComparisonOperator.NotEqual => Math.Abs(inputValue - Threshold) >= 0.000001, + ArduinoComparisonOperator.GreaterThanOrEqual => inputValue >= Threshold, + ArduinoComparisonOperator.GreaterThan => inputValue > Threshold, + _ => false + }; + + public string ToDisplayString() => $"{Pin} {OperatorToText(Operator)} {Threshold:0.##}"; + + private static string OperatorToText(ArduinoComparisonOperator comparison) => comparison switch + { + ArduinoComparisonOperator.LessThan => "<", + ArduinoComparisonOperator.LessThanOrEqual => "<=", + ArduinoComparisonOperator.Equal => "==", + ArduinoComparisonOperator.NotEqual => "!=", + ArduinoComparisonOperator.GreaterThanOrEqual => ">=", + ArduinoComparisonOperator.GreaterThan => ">", + _ => "?" + }; +} + +public sealed record ConditionalOutputRule( + int OutputPin, + ArduinoInputCondition Condition, + bool TrueState, + bool FalseState) +{ + public bool Evaluate(double inputValue) => Condition.Evaluate(inputValue) ? TrueState : FalseState; +} + +public sealed record DigitalOutputProfile( + DigitalOutputMode Mode, + bool InitialState, + double HighDurationSeconds, + double LowDurationSeconds); + public sealed class ArduinoSketchProgram { + private static readonly Regex DigitalWriteRegex = new( + @"\bdigitalWrite\s*\(\s*(?[^,()]+)\s*,\s*(?[^()]+?)\s*\)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static readonly Regex DelayRegex = new( + @"\b(?delay|delayMicroseconds)\s*\(\s*(?[^()]+?)\s*\)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private ArduinoSketchProgram( bool isValid, string diagnostic, double intervalSeconds, - IReadOnlyDictionary outputs) + IReadOnlyDictionary outputs, + IReadOnlyDictionary outputProfiles, + IReadOnlyList inputPins, + IReadOnlyList conditionalOutputs) { IsValid = isValid; Diagnostic = diagnostic; IntervalSeconds = intervalSeconds; Outputs = outputs; + OutputProfiles = outputProfiles; + InputPins = inputPins; + ConditionalOutputs = conditionalOutputs; } public bool IsValid { get; } public string Diagnostic { get; } public double IntervalSeconds { get; } public IReadOnlyDictionary Outputs { get; } + public IReadOnlyDictionary OutputProfiles { get; } + public IReadOnlyList InputPins { get; } + public IReadOnlyList ConditionalOutputs { get; } public static ArduinoSketchProgram Analyze(string? source) { var code = RemoveCommentsAndLiterals(source ?? string.Empty); - var bracesValid = HasBalancedBraces(code); - var hasSetup = Regex.IsMatch(code, @"\bvoid\s+setup\s*\(", RegexOptions.IgnoreCase); - var hasLoop = Regex.IsMatch(code, @"\bvoid\s+loop\s*\(", RegexOptions.IgnoreCase); - var hasDigitalWriteCall = Regex.IsMatch(code, @"\bdigitalWrite\s*\(", RegexOptions.IgnoreCase); - var writeMatches = Regex.Matches( - code, - @"\bdigitalWrite\s*\(\s*(\d+)\s*,\s*(HIGH|LOW)\s*\)", - RegexOptions.IgnoreCase); + var delimitersValid = HasBalancedDelimiters(code); + var hasSetup = TryExtractFunctionBody(code, "setup", out _); + var hasLoop = TryExtractFunctionBody(code, "loop", out var loopFunctionBody); + var constants = ResolveIntegerConstants(code); + var hasDigitalWriteCall = DigitalWriteRegex.IsMatch(code); - var parsedWrites = new List<(int Pin, string Level)>(); + var parsedWrites = new List(); var hasInvalidPinNumber = false; - foreach (Match match in writeMatches) + var hasUnsupportedOutputPin = false; + var hasUnsupportedOutputLevel = false; + foreach (Match match in DigitalWriteRegex.Matches(code)) { - if (!int.TryParse(match.Groups[1].Value, out var pinNumber)) + var pinResult = TryResolvePin(match.Groups["pin"].Value, constants); + if (pinResult.Status == PinResolutionStatus.InvalidRange) { hasInvalidPinNumber = true; continue; } - parsedWrites.Add((pinNumber, match.Groups[2].Value.ToUpperInvariant())); + if (pinResult.Status != PinResolutionStatus.Success) + { + hasUnsupportedOutputPin = true; + continue; + } + + if (!TryResolveLevel(match.Groups["level"].Value, constants, out var isHigh)) + { + hasUnsupportedOutputLevel = true; + continue; + } + + parsedWrites.Add(new WriteOperation(pinResult.PinNumber, isHigh)); } + var pinModes = ParsePinModes(code, constants); + var incorrectlyConfiguredPin = parsedWrites + .Select(write => write.PinNumber) + .Distinct() + .FirstOrDefault(pin => pinModes.TryGetValue(pin, out var mode) && + !string.Equals(mode, "OUTPUT", StringComparison.OrdinalIgnoreCase), -1); + var outputs = new Dictionary(); - foreach (var pinGroup in parsedWrites.GroupBy(write => write.Pin)) + foreach (var pinGroup in parsedWrites.GroupBy(write => write.PinNumber)) { - var levels = pinGroup.Select(write => write.Level).ToList(); - var hasHigh = levels.Contains("HIGH"); - var hasLow = levels.Contains("LOW"); + var hasHigh = pinGroup.Any(write => write.IsHigh); + var hasLow = pinGroup.Any(write => !write.IsHigh); outputs[pinGroup.Key] = hasHigh && hasLow ? DigitalOutputMode.Blink : hasHigh @@ -67,31 +170,533 @@ public static ArduinoSketchProgram Analyze(string? source) : DigitalOutputMode.Low; } - var delayMatch = Regex.Match(code, @"\bdelay\s*\(\s*(\d+)", RegexOptions.IgnoreCase); - var intervalSeconds = delayMatch.Success && double.TryParse(delayMatch.Groups[1].Value, out var delayMilliseconds) - ? Math.Clamp(delayMilliseconds / 1000d, 0.05, 10) - : 1; + var loopCode = hasLoop ? loopFunctionBody : code; + var inputPins = ParseInputPins(code, constants); + var conditionalOutputs = ParseConditionalOutputs(code, constants); + foreach (var rule in conditionalOutputs) + outputs[rule.OutputPin] = DigitalOutputMode.Conditional; - var isValid = bracesValid && hasSetup && hasLoop && outputs.Count > 0 && !hasInvalidPinNumber; + var outputProfiles = BuildOutputProfiles(loopCode, constants, outputs); + foreach (var rule in conditionalOutputs) + outputProfiles[rule.OutputPin] = new DigitalOutputProfile( + DigitalOutputMode.Conditional, + rule.FalseState, + 1, + 1); + var intervalSeconds = outputProfiles.Values + .FirstOrDefault(profile => profile.Mode == DigitalOutputMode.Blink)?.HighDurationSeconds ?? + ParseFirstDelaySeconds(loopCode, constants) ?? 1; + + var hasSupportedIo = outputs.Count > 0 || inputPins.Count > 0; + var hasUnsupportedSensorCondition = inputPins.Count > 0 && outputs.Count > 0 && + Regex.IsMatch(loopCode, @"\bif\s*\(", RegexOptions.IgnoreCase) && + conditionalOutputs.Count == 0; + var isValid = delimitersValid && hasSetup && hasLoop && hasSupportedIo && + !hasInvalidPinNumber && !hasUnsupportedOutputPin && !hasUnsupportedOutputLevel && + incorrectlyConfiguredPin < 0 && !hasUnsupportedSensorCondition; var diagnostic = isValid - ? "Sketch ready" - : !bracesValid - ? "Check braces" + ? inputPins.Count > 0 && outputs.Count == 0 ? "Input sketch ready" : "Sketch ready" + : !delimitersValid + ? "Check brackets and braces" : !hasSetup ? "Missing setup()" : !hasLoop ? "Missing loop()" : hasInvalidPinNumber - ? "Pin number is too large" - : hasDigitalWriteCall - ? "Use a numeric output pin" - : "No output write"; + ? "Pin number must be between 0 and 255" + : hasUnsupportedOutputPin + ? "Use a numeric or constant output pin" + : hasUnsupportedOutputLevel + ? "Use HIGH, LOW, 1, or 0" + : incorrectlyConfiguredPin >= 0 + ? $"Pin D{incorrectlyConfiguredPin} is not OUTPUT" + : hasUnsupportedSensorCondition + ? "Simplify the sensor if/else" + : hasDigitalWriteCall + ? "Unsupported output write" + : "No supported Arduino I/O"; return new ArduinoSketchProgram( isValid, diagnostic, intervalSeconds, - new ReadOnlyDictionary(outputs)); + new ReadOnlyDictionary(outputs), + new ReadOnlyDictionary(outputProfiles), + new ReadOnlyCollection(inputPins), + new ReadOnlyCollection(conditionalOutputs)); + } + + private static Dictionary ResolveIntegerConstants(string code) + { + var constants = new Dictionary(StringComparer.Ordinal) + { + ["LED_BUILTIN"] = 13, + ["HIGH"] = 1, + ["LOW"] = 0, + ["true"] = 1, + ["false"] = 0 + }; + var candidates = new List<(string Name, string Value)>(); + + foreach (Match match in Regex.Matches( + code, + @"(?m)^\s*#\s*define\s+(?[A-Za-z_]\w*)\s+(?[^\r\n]+)$")) + candidates.Add((match.Groups["name"].Value, match.Groups["value"].Value.Trim())); + + foreach (Match match in Regex.Matches( + code, + @"\b(?:(?:static|const|constexpr)\s+)*(?:unsigned\s+)?(?:char|byte|short|int|long|uint8_t|uint16_t|uint32_t|size_t)\s+(?:(?:const|constexpr)\s+)*(?[A-Za-z_]\w*)\s*=\s*(?[^,;]+)\s*;", + RegexOptions.CultureInvariant)) + candidates.Add((match.Groups["name"].Value, match.Groups["value"].Value.Trim())); + + for (var pass = 0; pass < candidates.Count + 1; pass++) + { + var changed = false; + foreach (var (name, value) in candidates) + { + if (constants.ContainsKey(name) || !TryResolveInteger(value, constants, out var resolved)) + continue; + + constants[name] = resolved; + changed = true; + } + + if (!changed) + break; + } + + return constants; + } + + private static Dictionary ParsePinModes(string code, IReadOnlyDictionary constants) + { + var result = new Dictionary(); + foreach (Match match in Regex.Matches( + code, + @"\bpinMode\s*\(\s*(?[^,()]+)\s*,\s*(?[A-Za-z_]\w*)\s*\)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + var pin = TryResolvePin(match.Groups["pin"].Value, constants); + if (pin.Status == PinResolutionStatus.Success) + result[pin.PinNumber] = match.Groups["mode"].Value; + } + + return result; + } + + private static List ParseInputPins(string code, IReadOnlyDictionary constants) + { + var result = new List(); + foreach (Match match in Regex.Matches( + code, + @"\b(?analogRead|digitalRead|pulseIn)\s*\(\s*(?[^,()]+)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + var pinExpression = match.Groups["pin"].Value.Trim(); + var pin = TryResolvePin(pinExpression, constants); + if (pin.Status != PinResolutionStatus.Success) + continue; + + var operation = match.Groups["operation"].Value; + var label = operation.Equals("analogRead", StringComparison.OrdinalIgnoreCase) + ? NormalizeAnalogPinLabel(pinExpression, pin.PinNumber) + : $"D{pin.PinNumber}"; + if (!result.Contains(label, StringComparer.OrdinalIgnoreCase)) + result.Add(label); + } + + if (Regex.IsMatch(code, @"\b(?:readTemperature|readHumidity)\s*\(\s*\)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + var dhtPin = ResolveDhtPin(code, constants); + if (!result.Contains(dhtPin, StringComparer.OrdinalIgnoreCase)) + result.Add(dhtPin); + } + + return result; + } + + private static List ParseConditionalOutputs( + string code, + IReadOnlyDictionary constants) + { + var dhtPin = ResolveDhtPin(code, constants); + var inputVariables = new Dictionary(StringComparer.Ordinal); + foreach (Match match in Regex.Matches( + code, + @"\b(?[A-Za-z_]\w*)\s*=\s*(?(?:(?:[A-Za-z_]\w*)\s*\.\s*)?(?:analogRead|digitalRead|pulseIn|readTemperature|readHumidity)\s*\([^;]*?\))\s*;", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + if (TryParseInputSource(match.Groups["input"].Value, constants, dhtPin, out var source)) + inputVariables[match.Groups["variable"].Value] = source; + } + + foreach (Match match in Regex.Matches( + code, + @"\b(?[A-Za-z_]\w*)\s*=\s*(?[A-Za-z_]\w*)\s*\*\s*(?\d+(?:\.\d+)?)\s*/\s*(?\d+(?:\.\d+)?)\s*;", + RegexOptions.CultureInvariant)) + { + if (!inputVariables.TryGetValue(match.Groups["source"].Value, out var source) || + source.Kind != ArduinoInputKind.PulseDurationMicroseconds || + !double.TryParse(match.Groups["factor"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, + out var factor) || + !double.TryParse(match.Groups["divisor"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, + out var divisor) || divisor <= 0) + continue; + + var centimetersPerMicrosecond = factor / divisor; + if (centimetersPerMicrosecond is >= 0.015 and <= 0.02) + inputVariables[match.Groups["variable"].Value] = + new InputSource(ArduinoInputKind.DistanceCentimeters, source.Pin); + } + + var rules = new List(); + foreach (Match match in Regex.Matches( + code, + @"\bif\s*\(\s*(?.+?)\s*(?>=|<=|==|!=|>|<)\s*(?[A-Za-z_]\w*|[-+]?(?:\d+(?:\.\d*)?|\.\d+))\s*\)\s*\{?\s*digitalWrite\s*\(\s*(?[^,()]+)\s*,\s*(?[^()]+?)\s*\)\s*;?\s*\}?\s*else\s*\{?\s*digitalWrite\s*\(\s*(?[^,()]+)\s*,\s*(?[^()]+?)\s*\)\s*;?", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline)) + { + var inputExpression = UnwrapParentheses(match.Groups["input"].Value); + if (!TryParseInputSource(inputExpression, constants, dhtPin, out var inputSource) && + !inputVariables.TryGetValue(inputExpression.Trim(), out inputSource)) + continue; + + var truePin = TryResolvePin(match.Groups["truePin"].Value, constants); + var falsePin = TryResolvePin(match.Groups["falsePin"].Value, constants); + if (truePin.Status != PinResolutionStatus.Success || falsePin.Status != PinResolutionStatus.Success || + truePin.PinNumber != falsePin.PinNumber || + !TryResolveLevel(match.Groups["trueLevel"].Value, constants, out var trueState) || + !TryResolveLevel(match.Groups["falseLevel"].Value, constants, out var falseState) || + !TryResolveDouble(match.Groups["threshold"].Value, constants, out var threshold) || + !TryParseComparison(match.Groups["comparison"].Value, out var comparison)) + continue; + + rules.Add(new ConditionalOutputRule( + truePin.PinNumber, + new ArduinoInputCondition(inputSource.Kind, inputSource.Pin, comparison, threshold), + trueState, + falseState)); + } + + return rules; + } + + private static bool TryParseInputSource( + string expression, + IReadOnlyDictionary constants, + string dhtPin, + out InputSource source) + { + var value = UnwrapParentheses(expression).Trim(); + var pinRead = Regex.Match( + value, + @"^(?analogRead|digitalRead|pulseIn)\s*\(\s*(?[^,()]+)(?:\s*,[^()]*)?\s*\)$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (pinRead.Success) + { + var pinExpression = pinRead.Groups["pin"].Value; + var pin = TryResolvePin(pinExpression, constants); + if (pin.Status == PinResolutionStatus.Success) + { + var operation = pinRead.Groups["operation"].Value; + if (operation.Equals("analogRead", StringComparison.OrdinalIgnoreCase)) + source = new InputSource(ArduinoInputKind.Analog, NormalizeAnalogPinLabel(pinExpression, pin.PinNumber)); + else if (operation.Equals("pulseIn", StringComparison.OrdinalIgnoreCase)) + source = new InputSource(ArduinoInputKind.PulseDurationMicroseconds, $"D{pin.PinNumber}"); + else + source = new InputSource(ArduinoInputKind.Digital, $"D{pin.PinNumber}"); + return true; + } + } + + if (Regex.IsMatch(value, @"(?:^|\.)readTemperature\s*\(\s*\)$", RegexOptions.IgnoreCase)) + { + source = new InputSource(ArduinoInputKind.TemperatureCelsius, dhtPin); + return true; + } + + if (Regex.IsMatch(value, @"(?:^|\.)readHumidity\s*\(\s*\)$", RegexOptions.IgnoreCase)) + { + source = new InputSource(ArduinoInputKind.RelativeHumidity, dhtPin); + return true; + } + + source = default; + return false; + } + + private static string ResolveDhtPin(string code, IReadOnlyDictionary constants) + { + var match = Regex.Match( + code, + @"\bDHT\s+[A-Za-z_]\w*\s*\(\s*(?[^,()]+)\s*,", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!match.Success) + return "D2"; + + var pin = TryResolvePin(match.Groups["pin"].Value, constants); + return pin.Status == PinResolutionStatus.Success ? $"D{pin.PinNumber}" : "D2"; + } + + private static bool TryResolveDouble( + string expression, + IReadOnlyDictionary constants, + out double value) + { + var token = UnwrapParentheses(expression).Trim(); + if (constants.TryGetValue(token, out var integerValue)) + { + value = integerValue; + return true; + } + + return double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out value) && + double.IsFinite(value); + } + + private static bool TryParseComparison(string value, out ArduinoComparisonOperator comparison) + { + comparison = value switch + { + "<" => ArduinoComparisonOperator.LessThan, + "<=" => ArduinoComparisonOperator.LessThanOrEqual, + "==" => ArduinoComparisonOperator.Equal, + "!=" => ArduinoComparisonOperator.NotEqual, + ">=" => ArduinoComparisonOperator.GreaterThanOrEqual, + ">" => ArduinoComparisonOperator.GreaterThan, + _ => default + }; + return value is "<" or "<=" or "==" or "!=" or ">=" or ">"; + } + + private static string NormalizeAnalogPinLabel(string expression, int pinNumber) + { + var match = Regex.Match(UnwrapParentheses(expression), @"^A(?\d+)$", RegexOptions.IgnoreCase); + if (match.Success) + return $"A{match.Groups["channel"].Value}"; + + return pinNumber is >= 14 and <= 21 ? $"A{pinNumber - 14}" : $"A{pinNumber}"; + } + + private static Dictionary BuildOutputProfiles( + string loopCode, + IReadOnlyDictionary constants, + IReadOnlyDictionary outputs) + { + var operations = new List(); + foreach (Match match in Regex.Matches( + loopCode, + @"\bdigitalWrite\s*\(\s*(?[^,()]+)\s*,\s*(?[^()]+?)\s*\)|\b(?delay|delayMicroseconds)\s*\(\s*(?[^()]+?)\s*\)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + if (match.Groups["pin"].Success) + { + var pin = TryResolvePin(match.Groups["pin"].Value, constants); + if (pin.Status == PinResolutionStatus.Success && + TryResolveLevel(match.Groups["level"].Value, constants, out var isHigh)) + operations.Add(TimedOperation.Write(pin.PinNumber, isHigh)); + continue; + } + + if (TryResolveInteger(match.Groups["value"].Value, constants, out var delayValue)) + { + var divisor = match.Groups["kind"].Value.Equals("delayMicroseconds", StringComparison.OrdinalIgnoreCase) + ? 1_000_000d + : 1_000d; + operations.Add(TimedOperation.Delay(Math.Max(0, delayValue / divisor))); + } + } + + var profiles = new Dictionary(); + foreach (var (pinNumber, mode) in outputs) + { + var pinWrites = operations.Where(operation => operation.PinNumber == pinNumber && operation.IsWrite).ToList(); + var initialState = pinWrites.FirstOrDefault()?.State ?? mode == DigitalOutputMode.High; + if (mode != DigitalOutputMode.Blink) + { + profiles[pinNumber] = new DigitalOutputProfile(mode, initialState, 1, 1); + continue; + } + + var state = pinWrites.LastOrDefault()?.State ?? initialState; + var highDuration = 0d; + var lowDuration = 0d; + foreach (var operation in operations) + { + if (operation.IsWrite && operation.PinNumber == pinNumber) + { + state = operation.State; + } + else if (!operation.IsWrite) + { + if (state) + highDuration += operation.DurationSeconds; + else + lowDuration += operation.DurationSeconds; + } + } + + highDuration = NormalizeSimulationDuration(highDuration > 0 ? highDuration : lowDuration); + lowDuration = NormalizeSimulationDuration(lowDuration > 0 ? lowDuration : highDuration); + profiles[pinNumber] = new DigitalOutputProfile(mode, initialState, highDuration, lowDuration); + } + + return profiles; + } + + private static double? ParseFirstDelaySeconds(string code, IReadOnlyDictionary constants) + { + var match = DelayRegex.Match(code); + if (!match.Success || !TryResolveInteger(match.Groups["value"].Value, constants, out var value)) + return null; + + var divisor = match.Groups["kind"].Value.Equals("delayMicroseconds", StringComparison.OrdinalIgnoreCase) + ? 1_000_000d + : 1_000d; + return NormalizeSimulationDuration(value / divisor); + } + + private static double NormalizeSimulationDuration(double seconds) => Math.Clamp(seconds, 0.05, 10); + + private static PinResolution TryResolvePin(string expression, IReadOnlyDictionary constants) + { + if (!TryResolveInteger(expression, constants, out var value)) + { + var token = UnwrapParentheses(expression).Trim(); + var looksNumeric = Regex.IsMatch(token, @"^[+-]?(?:\d|0[xXbB])"); + return new PinResolution(looksNumeric ? PinResolutionStatus.InvalidRange : PinResolutionStatus.Unsupported, 0); + } + + return value is >= 0 and <= 255 + ? new PinResolution(PinResolutionStatus.Success, (int)value) + : new PinResolution(PinResolutionStatus.InvalidRange, 0); + } + + private static bool TryResolveInteger( + string expression, + IReadOnlyDictionary constants, + out long value) + { + var token = UnwrapParentheses(expression).Trim(); + if (constants.TryGetValue(token, out value)) + return true; + + var digitalPin = Regex.Match(token, @"^D(?\d+)$", RegexOptions.IgnoreCase); + if (digitalPin.Success) + return long.TryParse(digitalPin.Groups["pin"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out value); + + var analogPin = Regex.Match(token, @"^A(?\d+)$", RegexOptions.IgnoreCase); + if (analogPin.Success && long.TryParse(analogPin.Groups["pin"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var channel)) + { + value = 14 + channel; + return true; + } + + token = Regex.Replace(token, @"(?i)(?:u|l)+$", string.Empty); + var sign = 1L; + if (token.StartsWith('-')) + { + sign = -1; + token = token[1..]; + } + else if (token.StartsWith('+')) + { + token = token[1..]; + } + + try + { + if (token.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + value = checked(sign * Convert.ToInt64(token[2..], 16)); + return true; + } + + if (token.StartsWith("0b", StringComparison.OrdinalIgnoreCase)) + { + value = checked(sign * Convert.ToInt64(token[2..], 2)); + return true; + } + + return long.TryParse($"{(sign < 0 ? "-" : string.Empty)}{token}", NumberStyles.Integer, + CultureInfo.InvariantCulture, out value); + } + catch (Exception exception) when (exception is FormatException or OverflowException) + { + value = 0; + return false; + } + } + + private static bool TryResolveLevel( + string expression, + IReadOnlyDictionary constants, + out bool isHigh) + { + var token = UnwrapParentheses(expression).Trim(); + if (token.Equals("HIGH", StringComparison.OrdinalIgnoreCase) || + token.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + isHigh = true; + return true; + } + + if (token.Equals("LOW", StringComparison.OrdinalIgnoreCase) || + token.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + isHigh = false; + return true; + } + + if (TryResolveInteger(token, constants, out var numericLevel) && numericLevel is 0 or 1) + { + isHigh = numericLevel == 1; + return true; + } + + isHigh = false; + return false; + } + + private static string UnwrapParentheses(string expression) + { + var result = expression.Trim(); + while (result.Length >= 2 && result[0] == '(' && result[^1] == ')' && + HasBalancedDelimiters(result[1..^1])) + result = result[1..^1].Trim(); + return result; + } + + private static bool TryExtractFunctionBody(string code, string functionName, out string body) + { + var functionMatch = Regex.Match( + code, + $@"\bvoid\s+{Regex.Escape(functionName)}\s*\([^)]*\)\s*\{{", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!functionMatch.Success) + { + body = string.Empty; + return false; + } + + var openBraceIndex = functionMatch.Index + functionMatch.Length - 1; + var depth = 0; + for (var index = openBraceIndex; index < code.Length; index++) + { + if (code[index] == '{') + depth++; + else if (code[index] == '}') + depth--; + + if (depth == 0) + { + body = code[(openBraceIndex + 1)..index]; + return true; + } + } + + body = string.Empty; + return false; } private static string RemoveCommentsAndLiterals(string source) @@ -183,21 +788,48 @@ private static string RemoveCommentsAndLiterals(string source) return new string(result); } - private static bool HasBalancedBraces(string code) + private static bool HasBalancedDelimiters(string code) { - var balance = 0; + var stack = new Stack(); foreach (var character in code) { - if (character == '{') - balance++; - else if (character == '}') - balance--; + if (character is '{' or '(' or '[') + { + stack.Push(character); + continue; + } + + if (character is not ('}' or ')' or ']')) + continue; + if (stack.Count == 0) + return false; - if (balance < 0) + var opening = stack.Pop(); + if (opening == '{' && character != '}' || opening == '(' && character != ')' || + opening == '[' && character != ']') return false; } - return balance == 0; + return stack.Count == 0; + } + + private sealed record WriteOperation(int PinNumber, bool IsHigh); + + private readonly record struct InputSource(ArduinoInputKind Kind, string Pin); + + private sealed record TimedOperation(bool IsWrite, int PinNumber, bool State, double DurationSeconds) + { + public static TimedOperation Write(int pinNumber, bool state) => new(true, pinNumber, state, 0); + public static TimedOperation Delay(double durationSeconds) => new(false, -1, false, durationSeconds); + } + + private readonly record struct PinResolution(PinResolutionStatus Status, int PinNumber); + + private enum PinResolutionStatus + { + Success, + Unsupported, + InvalidRange } private enum LexicalState diff --git a/SimForge.Core/CircuitAssistant.cs b/SimForge.Core/CircuitAssistant.cs new file mode 100644 index 0000000..a8fced8 --- /dev/null +++ b/SimForge.Core/CircuitAssistant.cs @@ -0,0 +1,216 @@ +using System.Collections.ObjectModel; + +namespace SimForge.Core; + +public enum GuidanceSeverity +{ + Info, + Warning, + Error +} + +public sealed record CircuitGuidanceIssue( + GuidanceSeverity Severity, + string Title, + string Instruction); + +public sealed record CircuitAssistantInput( + int ComponentCount, + bool HasController, + bool HasActuator, + bool HasFunctionalCircuit, + bool HasUnsafeLedPath, + int IncompleteLedCount, + IReadOnlyList UnpoweredSensors, + IReadOnlyList UnwiredSensors, + IReadOnlyList MissingInputPins, + IReadOnlyList MissingOutputPins, + bool SketchValid, + string SketchDiagnostic, + string? PreferredSensorName = null); + +public sealed record CircuitAssistantReport( + string Status, + string Summary, + IReadOnlyList Issues, + string? CodeExample); + +public static class CircuitAssistant +{ + public static CircuitAssistantReport Analyze(CircuitAssistantInput input) + { + ArgumentNullException.ThrowIfNull(input); + var issues = new List(); + + if (input.ComponentCount == 0) + { + issues.Add(new CircuitGuidanceIssue( + GuidanceSeverity.Info, + "Start with the controller", + "Add an Arduino, then add an LED or sensor from the component library.")); + } + else if (!input.HasController) + { + issues.Add(new CircuitGuidanceIssue( + GuidanceSeverity.Error, + "Controller missing", + "Add an Arduino-compatible board so the sketch has pins to read and drive.")); + } + + if (!input.SketchValid) + { + issues.Add(new CircuitGuidanceIssue( + GuidanceSeverity.Error, + "Sketch needs attention", + ExplainSketchDiagnostic(input.SketchDiagnostic))); + } + + if (input.HasUnsafeLedPath) + { + issues.Add(new CircuitGuidanceIssue( + GuidanceSeverity.Error, + "LED path is unsafe", + "Place a resistor between the controller output and LED anode before running.")); + } + + if (input.UnpoweredSensors.Count > 0) + { + issues.Add(new CircuitGuidanceIssue( + GuidanceSeverity.Warning, + "Sensor power is incomplete", + $"Connect VCC to 5V and GND to board GND for {JoinNames(input.UnpoweredSensors)}.")); + } + + if (input.UnwiredSensors.Count > 0) + { + issues.Add(new CircuitGuidanceIssue( + GuidanceSeverity.Warning, + "Sensor signal is not connected", + $"Wire the data pin to the matching controller input for {JoinNames(input.UnwiredSensors)}.")); + } + + if (input.HasController && input.MissingInputPins.Count > 0) + { + issues.Add(new CircuitGuidanceIssue( + GuidanceSeverity.Warning, + "Code reads an empty pin", + $"The sketch reads {string.Join(", ", input.MissingInputPins)}. Connect a compatible sensor output to that pin.")); + } + + if (input.HasController && input.MissingOutputPins.Count > 0) + { + issues.Add(new CircuitGuidanceIssue( + GuidanceSeverity.Warning, + "Code output is not used", + $"The sketch drives {string.Join(", ", input.MissingOutputPins.Select(pin => $"D{pin}"))}. Connect that pin to an actuator path.")); + } + + if (input.IncompleteLedCount > 0 && !input.HasUnsafeLedPath) + { + issues.Add(new CircuitGuidanceIssue( + GuidanceSeverity.Warning, + "LED loop is incomplete", + input.IncompleteLedCount == 1 + ? "Connect output → resistor → LED anode, then LED cathode → GND." + : $"Complete the source, resistor, and ground path for {input.IncompleteLedCount} LEDs.")); + } + + if (input.ComponentCount > 0 && !input.HasActuator && input.UnpoweredSensors.Count == 0 && + input.UnwiredSensors.Count == 0) + { + issues.Add(new CircuitGuidanceIssue( + GuidanceSeverity.Info, + "No visible output yet", + "Add an LED if you want to see the sketch response directly on the canvas.")); + } + + if (input.ComponentCount > 0 && !input.HasFunctionalCircuit && issues.Count == 0) + { + issues.Add(new CircuitGuidanceIssue( + GuidanceSeverity.Warning, + "Circuit is not complete", + "Check that every signal path has a source, destination, and ground return.")); + } + + var readOnlyIssues = new ReadOnlyCollection(issues); + if (issues.Count == 0) + { + return new CircuitAssistantReport( + "READY", + "Circuit and sketch agree. You can run the simulation.", + readOnlyIssues, + BuildCodeExample(input)); + } + + var errorCount = issues.Count(issue => issue.Severity == GuidanceSeverity.Error); + var warningCount = issues.Count(issue => issue.Severity == GuidanceSeverity.Warning); + var status = input.HasUnsafeLedPath + ? "UNSAFE" + : errorCount > 0 + ? "FIX" + : input.ComponentCount == 0 + ? "START" + : warningCount > 0 + ? "CHECK" + : input.HasFunctionalCircuit + ? "READY" + : "START"; + var summary = errorCount > 0 + ? $"{errorCount} blocking issue{(errorCount == 1 ? string.Empty : "s")} found. Fix the first item before running." + : warningCount > 0 + ? $"{warningCount} connection issue{(warningCount == 1 ? string.Empty : "s")} found." + : input.ComponentCount == 0 + ? "Follow the first step below to begin." + : input.HasFunctionalCircuit + ? "Circuit can run. The item below is an optional improvement." + : "Follow the first step below to begin."; + return new CircuitAssistantReport(status, summary, readOnlyIssues, BuildCodeExample(input)); + } + + private static string ExplainSketchDiagnostic(string diagnostic) + { + if (diagnostic.Contains("setup", StringComparison.OrdinalIgnoreCase)) + return "Add void setup() and configure output pins with pinMode(pin, OUTPUT)."; + if (diagnostic.Contains("loop", StringComparison.OrdinalIgnoreCase)) + return "Add void loop(); SimForge repeats the statements inside it."; + if (diagnostic.Contains("bracket", StringComparison.OrdinalIgnoreCase) || + diagnostic.Contains("brace", StringComparison.OrdinalIgnoreCase)) + return "Match every opening (, [, or { with a closing ), ], or }."; + if (diagnostic.Contains("not OUTPUT", StringComparison.OrdinalIgnoreCase)) + return "Change that pinMode to OUTPUT, or write to the pin configured as OUTPUT."; + if (diagnostic.Contains("numeric or constant", StringComparison.OrdinalIgnoreCase)) + return "Use a number, LED_BUILTIN, #define, or const int for the output pin."; + if (diagnostic.Contains("Simplify", StringComparison.OrdinalIgnoreCase)) + return "Use one sensor comparison and one digitalWrite in each if/else branch."; + if (diagnostic.Contains("I/O", StringComparison.OrdinalIgnoreCase)) + return "Add digitalWrite, analogRead, digitalRead, pulseIn, or a DHT read inside loop()."; + return diagnostic; + } + + private static string? BuildCodeExample(CircuitAssistantInput input) + { + if (!input.SketchValid) + { + return "void setup() {\n pinMode(13, OUTPUT);\n}\n\nvoid loop() {\n digitalWrite(13, HIGH);\n}"; + } + + return input.PreferredSensorName switch + { + "LDR Sensor" or "Potentiometer" => + "void setup() {\n pinMode(13, OUTPUT);\n}\n\nvoid loop() {\n int value = analogRead(A0);\n if (value > 600) {\n digitalWrite(13, HIGH);\n } else {\n digitalWrite(13, LOW);\n }\n}", + "HC-SR04 Distance" => + "const int trigPin = 7;\nconst int echoPin = 2;\n\nvoid setup() {\n pinMode(trigPin, OUTPUT);\n pinMode(echoPin, INPUT);\n pinMode(13, OUTPUT);\n}\n\nvoid loop() {\n digitalWrite(trigPin, LOW);\n delayMicroseconds(2);\n digitalWrite(trigPin, HIGH);\n delayMicroseconds(10);\n digitalWrite(trigPin, LOW);\n long duration = pulseIn(echoPin, HIGH);\n float distance = duration * 0.0343 / 2;\n if (distance < 20) {\n digitalWrite(13, HIGH);\n } else {\n digitalWrite(13, LOW);\n }\n}", + "DHT11 Temperature" => + "#include \n#define DHTPIN 2\nDHT dht(DHTPIN, DHT11);\n\nvoid setup() {\n dht.begin();\n pinMode(13, OUTPUT);\n}\n\nvoid loop() {\n float temperature = dht.readTemperature();\n if (temperature >= 30) {\n digitalWrite(13, HIGH);\n } else {\n digitalWrite(13, LOW);\n }\n delay(2000);\n}", + _ => null + }; + } + + private static string JoinNames(IReadOnlyList names) => names.Count switch + { + 0 => string.Empty, + 1 => names[0], + 2 => $"{names[0]} and {names[1]}", + _ => $"{string.Join(", ", names.Take(names.Count - 1))}, and {names[^1]}" + }; +} diff --git a/SimForge.Core/Node.cs b/SimForge.Core/Node.cs index 55c4a48..c933a5e 100644 --- a/SimForge.Core/Node.cs +++ b/SimForge.Core/Node.cs @@ -30,6 +30,14 @@ public NodePin GetPin(string name) => _pins.FirstOrDefault(pin => string.Equals(pin.Name, name, StringComparison.Ordinal)) ?? throw new KeyNotFoundException($"Pin '{name}' was not found on node '{Name}'."); + public void SetPinSignalValue(string pinName, double value) + { + if (!double.IsFinite(value)) + throw new ArgumentOutOfRangeException(nameof(value), "A pin signal value must be finite."); + + GetPin(pinName).Value = value; + } + public bool TryGetParameter(string name, out double value) => _parameters.TryGetValue(name, out value); @@ -79,6 +87,8 @@ public class MicrocontrollerNode : Node public MicrocontrollerNode(string name) : base(name, NodeKind.Microcontroller) { AddTerminal("D2", PinDirection.Input, PinSignalType.Digital); + AddTerminal("A0", PinDirection.Input, PinSignalType.Analog); + AddTerminal("D7", PinDirection.Bidirectional, PinSignalType.Digital); AddTerminal("D13", PinDirection.Output, PinSignalType.Digital); AddTerminal("5V", PinDirection.Output, PinSignalType.Power); AddTerminal("GND", PinDirection.Passive, PinSignalType.Ground); diff --git a/SimForge.Core/SensorSimulation.cs b/SimForge.Core/SensorSimulation.cs new file mode 100644 index 0000000..c53ad34 --- /dev/null +++ b/SimForge.Core/SensorSimulation.cs @@ -0,0 +1,112 @@ +namespace SimForge.Core; + +public readonly record struct AnalogSensorReading( + double Voltage, + int AdcValue, + int AdcMaximum, + double SourceResistanceOhms); + +public readonly record struct UltrasonicSensorReading( + double DistanceCentimeters, + double EchoDurationMicroseconds, + double SpeedOfSoundMetersPerSecond, + bool IsInRange); + +public readonly record struct Dht11SensorReading( + double TemperatureCelsius, + double HumidityPercent, + double MinimumSampleIntervalSeconds, + double TemperatureAccuracyCelsius); + +public static class SensorSimulation +{ + public const double DefaultSupplyVoltage = 5; + public const int DefaultAdcMaximum = 1023; + + public static AnalogSensorReading ReadPhotoresistor( + double ambientLightPercent, + double supplyVoltage = DefaultSupplyVoltage, + double fixedResistanceOhms = 10_000, + int adcMaximum = DefaultAdcMaximum) + { + ValidateAnalogArguments(supplyVoltage, fixedResistanceOhms, adcMaximum); + var normalizedLight = Math.Clamp(ambientLightPercent, 0, 100) / 100d; + + // A practical LDR spans roughly 1 MΩ in darkness to 1 kΩ in strong light. + // Interpolating logarithmically produces the non-linear response seen in real dividers. + var ldrResistance = Math.Pow(10, 6 - (3 * normalizedLight)); + var voltage = supplyVoltage * fixedResistanceOhms / (ldrResistance + fixedResistanceOhms); + return CreateAnalogReading(voltage, supplyVoltage, adcMaximum, ldrResistance); + } + + public static AnalogSensorReading ReadPotentiometer( + double wiperPositionPercent, + double supplyVoltage = DefaultSupplyVoltage, + double totalResistanceOhms = 10_000, + int adcMaximum = DefaultAdcMaximum) + { + ValidateAnalogArguments(supplyVoltage, totalResistanceOhms, adcMaximum); + var normalizedPosition = Math.Clamp(wiperPositionPercent, 0, 100) / 100d; + var voltage = supplyVoltage * normalizedPosition; + var sourceResistance = totalResistanceOhms * normalizedPosition * (1 - normalizedPosition); + return CreateAnalogReading(voltage, supplyVoltage, adcMaximum, sourceResistance); + } + + public static UltrasonicSensorReading ReadHcSr04( + double targetDistanceCentimeters, + double ambientTemperatureCelsius = 20) + { + var speedOfSound = 331.3 + (0.606 * ambientTemperatureCelsius); + var inRange = targetDistanceCentimeters is >= 2 and <= 400; + var clampedDistance = Math.Clamp(targetDistanceCentimeters, 0, 1_000); + var roundTripMeters = clampedDistance * 2 / 100d; + var echoDurationMicroseconds = roundTripMeters / speedOfSound * 1_000_000d; + return new UltrasonicSensorReading( + targetDistanceCentimeters, + echoDurationMicroseconds, + speedOfSound, + inRange); + } + + public static double DistanceFromEchoMicroseconds( + double echoDurationMicroseconds, + double ambientTemperatureCelsius = 20) + { + if (echoDurationMicroseconds < 0) + throw new ArgumentOutOfRangeException(nameof(echoDurationMicroseconds)); + + var speedOfSound = 331.3 + (0.606 * ambientTemperatureCelsius); + return echoDurationMicroseconds / 1_000_000d * speedOfSound / 2 * 100; + } + + public static Dht11SensorReading ReadDht11(double temperatureCelsius, double humidityPercent = 55) + { + var clampedTemperature = Math.Clamp(temperatureCelsius, 0, 50); + var clampedHumidity = Math.Clamp(humidityPercent, 20, 90); + return new Dht11SensorReading( + Math.Round(clampedTemperature, MidpointRounding.AwayFromZero), + Math.Round(clampedHumidity, MidpointRounding.AwayFromZero), + 2, + 2); + } + + private static AnalogSensorReading CreateAnalogReading( + double voltage, + double supplyVoltage, + int adcMaximum, + double sourceResistanceOhms) + { + var adcValue = (int)Math.Round(voltage / supplyVoltage * adcMaximum, MidpointRounding.AwayFromZero); + return new AnalogSensorReading(voltage, Math.Clamp(adcValue, 0, adcMaximum), adcMaximum, sourceResistanceOhms); + } + + private static void ValidateAnalogArguments(double supplyVoltage, double resistanceOhms, int adcMaximum) + { + if (supplyVoltage <= 0) + throw new ArgumentOutOfRangeException(nameof(supplyVoltage)); + if (resistanceOhms <= 0) + throw new ArgumentOutOfRangeException(nameof(resistanceOhms)); + if (adcMaximum <= 0) + throw new ArgumentOutOfRangeException(nameof(adcMaximum)); + } +} diff --git a/SimForge/App.axaml.cs b/SimForge/App.axaml.cs index 0bda2cc..3667c76 100644 --- a/SimForge/App.axaml.cs +++ b/SimForge/App.axaml.cs @@ -1,6 +1,8 @@ -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Markup.Xaml; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using System; namespace SimForge; @@ -13,11 +15,22 @@ public override void Initialize() public override void OnFrameworkInitializationCompleted() { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - desktop.MainWindow = new MainWindow(); - } + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var mainWindow = new MainWindow(); + desktop.MainWindow = mainWindow; + + if (Array.Exists(desktop.Args ?? [], argument => argument == "--smoke-test")) + { + mainWindow.Opened += (_, _) => + { + Console.WriteLine("SimForge smoke test: main window opened."); + Console.Out.Flush(); + Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Background); + }; + } + } base.OnFrameworkInitializationCompleted(); } -} \ No newline at end of file +} diff --git a/SimForge/MainWindow.axaml b/SimForge/MainWindow.axaml index 18e147c..01814a6 100644 --- a/SimForge/MainWindow.axaml +++ b/SimForge/MainWindow.axaml @@ -447,6 +447,36 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -478,7 +508,7 @@ VerticalAlignment="Center" Margin="17,0,0,0" /> - diff --git a/SimForge/MainWindow.axaml.cs b/SimForge/MainWindow.axaml.cs index 031d0a2..9115745 100644 --- a/SimForge/MainWindow.axaml.cs +++ b/SimForge/MainWindow.axaml.cs @@ -26,13 +26,15 @@ public partial class MainWindow : Window private readonly List _categoryEntries = new(); private double _timeSeconds; - private double _blinkAccumulatorSeconds; - private double _blinkPeriodSeconds = 1; private bool _isSimulationRunning; private bool _hasShortCircuit; private bool _isUpdatingInspector; - private readonly Dictionary _sketchPinModes = new(); + private readonly Dictionary _sketchOutputProfiles = new(); + private readonly List _conditionalOutputRules = new(); + private readonly Dictionary _pinPhaseElapsedSeconds = new(); private readonly Dictionary _digitalPinStates = new(); + private readonly HashSet _sketchInputPins = new(StringComparer.OrdinalIgnoreCase); + private ArduinoSketchProgram? _lastSketchAnalysis; private Border? _selectedComponent; private VisualConnection? _selectedConnection; private Border? _connectionStart; @@ -68,7 +70,10 @@ private void ConfigureAccessibility() AutomationProperties.SetName(ComponentSearchBox, "Search components"); AutomationProperties.SetHelpText(ComponentSearchBox, "Filter the component library by name, type, or capability."); AutomationProperties.SetName(CodeEditorTextBox, "Arduino C++ sketch editor"); - AutomationProperties.SetHelpText(CodeEditorTextBox, "Edit the setup and loop functions that drive the simulation."); + AutomationProperties.SetHelpText(CodeEditorTextBox, + "Edit setup and loop. SimForge supports pin constants, reads, writes, delays, and simple sensor if/else rules."); + ToolTip.SetTip(CodeEditorTextBox, + "Simulation subset: pin constants, digitalWrite, analogRead, digitalRead, pulseIn, DHT reads, and simple if/else."); AutomationProperties.SetName(LedColorComboBox, "LED emitter color"); AutomationProperties.SetName(SignalValueSlider, "Component input value"); AutomationProperties.SetName(GraphCanvas, "Circuit design canvas"); @@ -76,6 +81,7 @@ private void ConfigureAccessibility() AutomationProperties.SetLiveSetting(StatusText, AutomationLiveSetting.Polite); AutomationProperties.SetLiveSetting(HintText, AutomationLiveSetting.Polite); AutomationProperties.SetLiveSetting(CodeStatusText, AutomationLiveSetting.Polite); + AutomationProperties.SetLiveSetting(AssistantSummaryText, AutomationLiveSetting.Polite); AutomationProperties.SetLiveSetting(ShortCircuitBadge, AutomationLiveSetting.Assertive); } @@ -262,7 +268,7 @@ private void StartSimulation() { StatusText.Text = "Code issue"; HintText.Text = "Fix the reported sketch structure issue before starting the simulation."; - FooterText.Text = "SimForge 0.4.0 · Simulation blocked by sketch diagnostics"; + FooterText.Text = "SimForge 0.7.0 · Simulation blocked by sketch diagnostics"; return; } @@ -272,15 +278,15 @@ private void StartSimulation() { StatusText.Text = "Safety lock"; HintText.Text = "Simulation blocked: add a current-limiting resistor to the unsafe LED path."; - FooterText.Text = "SimForge 0.4.0 · Simulation blocked by electrical safety"; + FooterText.Text = "SimForge 0.7.0 · Simulation blocked by electrical safety"; return; } if (!circuitReady) { StatusText.Text = "Circuit incomplete"; - HintText.Text = "Simulation needs a driven output, a protected LED path, and a ground return."; - FooterText.Text = "SimForge 0.4.0 · Complete the circuit before running"; + HintText.Text = "Complete an LED path or wire a powered sensor signal to a matching controller input."; + FooterText.Text = "SimForge 0.7.0 · Complete the circuit before running"; return; } @@ -295,9 +301,9 @@ private void StartSimulation() ArduinoStatus.Text = "Simulation live"; ArduinoStatus.Foreground = Brush("#79DBB9"); StatusText.Text = "Running"; - var drivenPins = string.Join(", ", _sketchPinModes.Keys.OrderBy(pin => pin).Select(pin => $"D{pin}")); - HintText.Text = $"The sketch is driving {drivenPins} with a {_blinkPeriodSeconds:0.##} s timing interval."; - FooterText.Text = "SimForge 0.4.0 · Live simulation"; + ApplyConditionalOutputs(); + HintText.Text = BuildSimulationHint(); + FooterText.Text = "SimForge 0.7.0 · Live simulation"; EvaluateCircuitState(); } @@ -314,7 +320,7 @@ private void StopSimulation() ArduinoStatus.Text = "Simulation paused"; ArduinoStatus.Foreground = Brush("#C2A26D"); StatusText.Text = "Paused"; - FooterText.Text = "SimForge 0.4.0 · Simulation paused"; + FooterText.Text = "SimForge 0.7.0 · Simulation paused"; EvaluateCircuitState(); } @@ -322,7 +328,7 @@ private void ResetButton_Click(object? sender, RoutedEventArgs e) { ResetSimulationState(); HintText.Text = "Simulation state reset. Your circuit and sketch were preserved."; - FooterText.Text = "SimForge 0.4.0 · Simulation reset"; + FooterText.Text = "SimForge 0.7.0 · Simulation reset"; } private void ResetSimulationState() @@ -330,9 +336,11 @@ private void ResetSimulationState() _simulationTimer.Stop(); _isSimulationRunning = false; _timeSeconds = 0; - _blinkAccumulatorSeconds = 0; - foreach (var pin in _sketchPinModes.Keys.ToList()) - _digitalPinStates[pin] = _sketchPinModes[pin] == DigitalOutputMode.High; + foreach (var (pinNumber, profile) in _sketchOutputProfiles) + { + _digitalPinStates[pinNumber] = profile.InitialState; + _pinPhaseElapsedSeconds[pinNumber] = 0; + } TimeText.Text = "0.000 s"; RunButton.IsEnabled = true; StopButton.IsEnabled = false; @@ -378,6 +386,7 @@ private Border AddComponent(string componentName, Point? position = null, bool s LedColor = "Red" }; _addedComponents.Add(node, editorComponent); + UpdateSensorReading(editorComponent); _addedComponentCount++; if (select) @@ -787,7 +796,7 @@ private void SelectComponent(Border node) InspectorSymbolBorder.BorderBrush = Brush(WithAlpha(info.Accent, "77")); GeneralInfoText.Text = info.Summary; PinsText.Text = string.Join(" · ", component.Model.Pins.Select(pin => $"{pin.Name} [{pin.SignalType}]").ToArray()); - ParametersText.Text = BuildParameterSummary(info, component.Model.ComponentValue); + ParametersText.Text = BuildComponentParameterSummary(component); TutorialText.Text = info.Tutorial; HintText.Text = $"{info.Name} selected. Inspect its pins, parameters, or start a connection."; StatusText.Text = "Selected"; @@ -818,6 +827,7 @@ private void SelectComponent(Border node) UpdateSignalValueText(info, component.Model.ComponentValue); } _isUpdatingInspector = false; + UpdateCircuitAssistant(); } private void SelectConnection(VisualConnection visualConnection) @@ -843,6 +853,7 @@ private void SelectConnection(VisualConnection visualConnection) TutorialText.Text = "Select the wire and press Delete to remove it. Routing updates automatically when nodes move."; HintText.Text = "This wire passed SimForge signal compatibility checks."; StatusText.Text = "Wire selected"; + UpdateCircuitAssistant(); } private void ShowWorkspaceInspector() @@ -862,6 +873,7 @@ private void ShowWorkspaceInspector() TutorialText.Text = "Select a node, choose Connect, then click a compatible target. Press Escape to cancel connection mode."; LedColorPanel.IsVisible = false; SignalValuePanel.IsVisible = false; + UpdateCircuitAssistant(); } private void RemoveButton_Click(object? sender, RoutedEventArgs e) => RemoveSelectedElement(); @@ -1046,11 +1058,26 @@ private int ScorePinPair(EditorComponent first, NodePin from, EditorComponent se if (second.Model.Kind == NodeKind.Microcontroller && to.Name == "D13" && from.SignalType != PinSignalType.Power) score += first.Info.Name == "GND" ? -30 : 30; + if (first.Model.Kind == NodeKind.Microcontroller && second.Model.Kind == NodeKind.Sensor) + score += ScoreSketchAwareControllerPin(from, to); + if (second.Model.Kind == NodeKind.Microcontroller && first.Model.Kind == NodeKind.Sensor) + score += ScoreSketchAwareControllerPin(to, from); score -= GetPinUseCount(from) * 45; score -= GetPinUseCount(to) * 45; return score; } + private int ScoreSketchAwareControllerPin(NodePin controllerPin, NodePin sensorPin) + { + var score = 0; + if (sensorPin.Direction == PinDirection.Output && _sketchInputPins.Contains(controllerPin.Name)) + score += 70; + if (sensorPin.Direction == PinDirection.Input && TryGetDigitalPinNumber(controllerPin, out var pinNumber) && + _sketchOutputProfiles.ContainsKey(pinNumber)) + score += 55; + return score; + } + private int GetPinUseCount(NodePin pin) => _visualConnections.Count(connection => ReferenceEquals(connection.Model.From, pin) || ReferenceEquals(connection.Model.To, pin)); @@ -1059,7 +1086,7 @@ private void CheckShortCircuit() var sourcePins = _addedComponents.Values .Where(component => component.Model.Kind == NodeKind.Microcontroller) .SelectMany(component => component.Model.Pins) - .Where(pin => pin.Direction == PinDirection.Output && + .Where(pin => pin.Direction is PinDirection.Output or PinDirection.Bidirectional && pin.SignalType is PinSignalType.Digital or PinSignalType.Power) .ToList(); var groundPins = GetGroundReferencePins().ToList(); @@ -1109,7 +1136,7 @@ private void ApplySafetyLock() ArduinoStatus.Foreground = Brush("#FF9AAE"); StatusText.Text = "Safety lock"; HintText.Text = "Simulation stopped immediately because the circuit became unsafe. Correct the LED path before running again."; - FooterText.Text = "SimForge 0.4.0 · Emergency safety stop"; + FooterText.Text = "SimForge 0.7.0 · Emergency safety stop"; } private bool EvaluateCircuitState() @@ -1119,6 +1146,8 @@ private bool EvaluateCircuitState() var drivenPins = GetDrivenPins().ToList(); var groundPins = GetGroundReferencePins().ToList(); var leds = _addedComponents.Values.Where(component => component.Info.Name == "LED").ToList(); + var onlineSensors = GetOnlineSensors().ToList(); + circuitReady |= onlineSensors.Count > 0; foreach (var led in leds) { @@ -1152,7 +1181,15 @@ private bool EvaluateCircuitState() } else if (circuitReady) { - CircuitStateText.Text = _isSimulationRunning ? (anyLedOn ? "Live · output high" : "Live · output low") : "Closed · ready"; + CircuitStateText.Text = _isSimulationRunning + ? anyLedOn + ? "Live · output high" + : onlineSensors.Count > 0 + ? $"Live · {onlineSensors.Count} sensor{(onlineSensors.Count == 1 ? string.Empty : "s")} online" + : "Live · output low" + : onlineSensors.Count > 0 + ? $"Ready · {onlineSensors.Count} sensor{(onlineSensors.Count == 1 ? string.Empty : "s")} online" + : "Closed · ready"; CircuitStateText.Foreground = Brush("#5BD39E"); } else if (_visualConnections.Count == 0) @@ -1165,15 +1202,144 @@ private bool EvaluateCircuitState() CircuitStateText.Text = "Incomplete loop"; CircuitStateText.Foreground = Brush("#D2A25C"); } + UpdateCircuitAssistant(circuitReady); return circuitReady; } + private void UpdateCircuitAssistant(bool? knownCircuitReady = null) + { + var sensors = _addedComponents.Values.Where(component => component.Info.HasAdjustableValue).ToList(); + var unpoweredSensors = sensors + .Where(sensor => !IsSensorPowered(sensor)) + .Select(sensor => sensor.Info.Name) + .Distinct() + .ToList(); + var unwiredSensors = sensors + .Where(sensor => IsSensorPowered(sensor) && !IsSensorSignalConnected(sensor)) + .Select(sensor => sensor.Info.Name) + .Distinct() + .ToList(); + var missingInputPins = _sketchInputPins + .Where(pin => !IsControllerInputConnected(pin)) + .OrderBy(pin => pin) + .ToList(); + var missingOutputPins = (_lastSketchAnalysis?.Outputs.Keys ?? Enumerable.Empty()) + .Where(pin => !IsControllerOutputConnected(pin)) + .OrderBy(pin => pin) + .ToList(); + var leds = _addedComponents.Values.Where(component => component.Info.Name == "LED").ToList(); + var incompleteLedCount = leds.Count(led => !IsLedPathComplete(led)); + var selectedSensorName = _selectedComponent is not null && + _addedComponents.TryGetValue(_selectedComponent, out var selected) && + selected.Info.HasAdjustableValue + ? selected.Info.Name + : sensors.FirstOrDefault()?.Info.Name; + var circuitReady = knownCircuitReady ?? + (sensors.Any(sensor => IsSensorPowered(sensor) && IsSensorSignalConnected(sensor)) || + leds.Any(IsLedPathComplete)); + + var report = CircuitAssistant.Analyze(new CircuitAssistantInput( + _addedComponents.Count, + _addedComponents.Values.Any(component => component.Model.Kind == NodeKind.Microcontroller), + leds.Count > 0, + circuitReady, + _hasShortCircuit, + incompleteLedCount, + unpoweredSensors, + unwiredSensors, + missingInputPins, + missingOutputPins, + _lastSketchAnalysis?.IsValid ?? false, + _lastSketchAnalysis?.Diagnostic ?? "No supported Arduino I/O", + selectedSensorName)); + RenderCircuitAssistant(report); + } + + private bool IsLedPathComplete(EditorComponent led) + { + var anode = led.Model.Pins.FirstOrDefault(pin => pin.Name == "Anode"); + var cathode = led.Model.Pins.FirstOrDefault(pin => pin.Name == "Cathode"); + if (anode is null || cathode is null || _hasShortCircuit) + return false; + + var hasProtectedSource = GetDrivenPins().Any(source => + HasPinPath(source.Pin, anode, PathLimiterRequirement.Required)); + var hasGroundReturn = GetGroundReferencePins().Any(ground => + HasPinPath(cathode, ground, PathLimiterRequirement.Any)); + return hasProtectedSource && hasGroundReturn; + } + + private bool IsControllerInputConnected(string pinName) + { + var inputs = _addedComponents.Values + .Where(component => component.Model.Kind == NodeKind.Microcontroller) + .SelectMany(component => component.Model.Pins) + .Where(pin => pin.Name.Equals(pinName, StringComparison.OrdinalIgnoreCase) && + pin.Direction is PinDirection.Input or PinDirection.Bidirectional) + .ToList(); + return _addedComponents.Values + .Where(component => component.Info.HasAdjustableValue) + .SelectMany(component => component.Model.Pins) + .Where(pin => pin.Direction == PinDirection.Output) + .Any(output => inputs.Any(input => HasPinPath(output, input, PathLimiterRequirement.Any))); + } + + private bool IsControllerOutputConnected(int pinNumber) + { + var pinName = $"D{pinNumber}"; + var outputs = _addedComponents.Values + .Where(component => component.Model.Kind == NodeKind.Microcontroller) + .SelectMany(component => component.Model.Pins) + .Where(pin => pin.Name.Equals(pinName, StringComparison.OrdinalIgnoreCase) && + pin.Direction is PinDirection.Output or PinDirection.Bidirectional) + .ToList(); + return outputs.Any(output => _visualConnections.Any(connection => connection.Model.IsConnectedTo(output))); + } + + private void RenderCircuitAssistant(CircuitAssistantReport report) + { + AssistantStatusText.Text = report.Status; + AssistantSummaryText.Text = report.Summary; + var (foreground, background) = report.Status switch + { + "READY" => ("#6DE0B0", "#17382E"), + "UNSAFE" or "FIX" => ("#FF9AAE", "#3A1D29"), + "CHECK" => ("#F0C06A", "#3A2D19"), + _ => ("#7FB4E8", "#172C42") + }; + AssistantStatusText.Foreground = Brush(foreground); + AssistantStatusBadge.Background = Brush(background); + + if (report.Issues.Count == 0) + { + AssistantIssuesText.Text = "✓ No blocking circuit issues detected."; + AssistantIssuesText.Foreground = Brush("#7BCDAC"); + } + else + { + const int visibleIssueLimit = 4; + var issueLines = report.Issues.Take(visibleIssueLimit) + .Select((issue, index) => $"{index + 1}. {issue.Title} — {issue.Instruction}") + .ToList(); + if (report.Issues.Count > visibleIssueLimit) + issueLines.Add($"+ {report.Issues.Count - visibleIssueLimit} more issue(s)"); + AssistantIssuesText.Text = string.Join(Environment.NewLine, issueLines); + AssistantIssuesText.Foreground = Brush(report.Issues.Any(issue => issue.Severity == GuidanceSeverity.Error) + ? "#E7A4B1" + : "#C8B889"); + } + + AssistantCodePanel.IsVisible = !string.IsNullOrWhiteSpace(report.CodeExample); + AssistantCodeText.Text = report.CodeExample ?? string.Empty; + } + private IEnumerable GetDrivenPins() { foreach (var component in _addedComponents.Values.Where(component => component.Model.Kind == NodeKind.Microcontroller)) { - foreach (var pin in component.Model.Pins.Where(pin => pin.Direction == PinDirection.Output)) + foreach (var pin in component.Model.Pins.Where(pin => + pin.Direction is PinDirection.Output or PinDirection.Bidirectional)) { if (pin.SignalType == PinSignalType.Power) { @@ -1187,17 +1353,43 @@ private IEnumerable GetDrivenPins() } } + } + + private IEnumerable GetOnlineSensors() + { foreach (var sensor in _addedComponents.Values.Where(component => component.Info.HasAdjustableValue)) { - if (!IsSensorPowered(sensor)) - continue; - - var isHigh = IsSensorOutputActive(sensor.Info, sensor.Model.ComponentValue); - foreach (var output in sensor.Model.Pins.Where(pin => pin.Direction == PinDirection.Output)) - yield return new DrivenPin(output, isHigh); + UpdateSensorReading(sensor); + if (IsSensorPowered(sensor) && IsSensorSignalConnected(sensor)) + yield return sensor; } } + private bool IsSensorSignalConnected(EditorComponent sensor) + { + var controllerInputs = _addedComponents.Values + .Where(component => component.Model.Kind == NodeKind.Microcontroller) + .SelectMany(component => component.Model.Pins) + .Where(pin => pin.Direction is PinDirection.Input or PinDirection.Bidirectional) + .ToList(); + var hasDataPath = sensor.Model.Pins + .Where(pin => pin.Direction == PinDirection.Output) + .Any(output => controllerInputs.Any(input => HasPinPath(output, input, PathLimiterRequirement.Any))); + if (!hasDataPath) + return false; + + if (sensor.Info.Name != "HC-SR04 Distance") + return true; + + var trigger = sensor.Model.Pins.FirstOrDefault(pin => pin.Name == "TRIG"); + if (trigger is null) + return false; + + return GetDrivenPins().Any(source => + source.Pin.SignalType == PinSignalType.Digital && + HasPinPath(source.Pin, trigger, PathLimiterRequirement.Any)); + } + private bool IsSensorPowered(EditorComponent sensor) { var supplyPin = sensor.Model.Pins.FirstOrDefault(pin => pin.SignalType == PinSignalType.Power); @@ -1216,8 +1408,100 @@ private bool IsSensorPowered(EditorComponent sensor) return hasSupply && hasGround; } + private bool ApplyConditionalOutputs() + { + var changed = false; + foreach (var rule in _conditionalOutputRules) + { + var next = TryReadSensorInput(rule.Condition, out var inputValue) + ? rule.Evaluate(inputValue) + : rule.FalseState; + var previous = _digitalPinStates.GetValueOrDefault(rule.OutputPin); + if (previous == next) + continue; + + _digitalPinStates[rule.OutputPin] = next; + changed = true; + } + + return changed; + } + + private bool TryReadSensorInput(ArduinoInputCondition condition, out double value) + { + var controllerPins = _addedComponents.Values + .Where(component => component.Model.Kind == NodeKind.Microcontroller) + .SelectMany(component => component.Model.Pins) + .Where(pin => pin.Name.Equals(condition.Pin, StringComparison.OrdinalIgnoreCase) && + pin.Direction is PinDirection.Input or PinDirection.Bidirectional) + .ToList(); + + foreach (var sensor in _addedComponents.Values.Where(component => component.Info.HasAdjustableValue)) + { + if (!IsSensorPowered(sensor) || !SensorCanProvide(sensor.Info.Name, condition.Kind)) + continue; + + var hasDataPath = sensor.Model.Pins + .Where(pin => pin.Direction == PinDirection.Output) + .Any(output => controllerPins.Any(input => HasPinPath(output, input, PathLimiterRequirement.Any))); + if (!hasDataPath || + (condition.Kind is ArduinoInputKind.PulseDurationMicroseconds or ArduinoInputKind.DistanceCentimeters && + !IsSensorSignalConnected(sensor))) + continue; + + UpdateSensorReading(sensor); + value = condition.Kind switch + { + ArduinoInputKind.Analog when sensor.Info.Name == "LDR Sensor" => + SensorSimulation.ReadPhotoresistor(sensor.Model.ComponentValue).AdcValue, + ArduinoInputKind.Analog => SensorSimulation.ReadPotentiometer(sensor.Model.ComponentValue).AdcValue, + ArduinoInputKind.PulseDurationMicroseconds => + SensorSimulation.ReadHcSr04(sensor.Model.ComponentValue).EchoDurationMicroseconds, + ArduinoInputKind.DistanceCentimeters => sensor.Model.ComponentValue, + ArduinoInputKind.TemperatureCelsius => + SensorSimulation.ReadDht11(sensor.Model.ComponentValue).TemperatureCelsius, + ArduinoInputKind.RelativeHumidity => + SensorSimulation.ReadDht11(sensor.Model.ComponentValue).HumidityPercent, + ArduinoInputKind.Digital => sensor.Model.Pins + .Where(pin => pin.Direction == PinDirection.Output) + .Select(pin => pin.Value) + .FirstOrDefault() > 0 ? 1 : 0, + _ => 0 + }; + return true; + } + + value = 0; + return false; + } + + private static bool SensorCanProvide(string sensorName, ArduinoInputKind inputKind) => inputKind switch + { + ArduinoInputKind.Analog => sensorName is "LDR Sensor" or "Potentiometer", + ArduinoInputKind.PulseDurationMicroseconds => sensorName == "HC-SR04 Distance", + ArduinoInputKind.DistanceCentimeters => sensorName == "HC-SR04 Distance", + ArduinoInputKind.TemperatureCelsius or ArduinoInputKind.RelativeHumidity => sensorName == "DHT11 Temperature", + ArduinoInputKind.Digital => sensorName is "HC-SR04 Distance" or "DHT11 Temperature", + _ => false + }; + + private string? BuildConditionalReactionSummary() + { + var reactions = new List(); + foreach (var rule in _conditionalOutputRules) + { + if (!TryReadSensorInput(rule.Condition, out var inputValue)) + continue; + + var state = rule.Evaluate(inputValue) ? "HIGH" : "LOW"; + reactions.Add($"{rule.Condition.Pin} {inputValue:0.##} → D{rule.OutputPin} {state}"); + } + + return reactions.Count == 0 ? null : string.Join(" · ", reactions); + } + private IEnumerable GetGroundReferencePins() => _addedComponents.Values - .Where(component => component.Info.Name == "GND") + .Where(component => component.Info.Name == "GND" || component.Model.Kind == NodeKind.Microcontroller) .SelectMany(component => component.Model.Pins) .Where(pin => pin.SignalType == PinSignalType.Ground); @@ -1300,9 +1584,17 @@ private void SignalValueSlider_ValueChanged(object? sender, RangeBaseValueChange return; component.Model.ComponentValue = e.NewValue; + UpdateSensorReading(component); UpdateSignalValueText(component.Info, e.NewValue); - ParametersText.Text = BuildParameterSummary(component.Info, e.NewValue); - HintText.Text = $"{component.Info.ValueLabel} updated. Simulated output is {(IsSensorOutputActive(component.Info, e.NewValue) ? "HIGH" : "LOW")}."; + ParametersText.Text = BuildComponentParameterSummary(component); + HintText.Text = BuildSensorChangeHint(component.Info, e.NewValue); + if (_isSimulationRunning) + { + ApplyConditionalOutputs(); + var reaction = BuildConditionalReactionSummary(); + if (!string.IsNullOrEmpty(reaction)) + HintText.Text += $" · {reaction}"; + } EvaluateCircuitState(); } @@ -1311,13 +1603,83 @@ private void UpdateSignalValueText(ComponentInfo info, double value) SignalValueText.Text = $"{value:0.#}{info.ValueUnit}"; } - private static bool IsSensorOutputActive(ComponentInfo info, double value) => - info.TriggerAbove ? value >= info.TriggerThreshold : value <= info.TriggerThreshold; - private static string BuildParameterSummary(ComponentInfo info, double value) => - info.HasAdjustableValue - ? $"{info.Parameters}\nSimulated output: {(IsSensorOutputActive(info, value) ? "HIGH" : "LOW")} at {info.TriggerThreshold:0.#}{info.ValueUnit}" - : info.Parameters; + info.Name switch + { + "LDR Sensor" => BuildPhotoresistorSummary(info, value), + "Potentiometer" => BuildPotentiometerSummary(info, value), + "HC-SR04 Distance" => BuildUltrasonicSummary(info, value), + "DHT11 Temperature" => BuildDht11Summary(info, value), + _ => info.Parameters + }; + + private string BuildComponentParameterSummary(EditorComponent component) + { + var summary = BuildParameterSummary(component.Info, component.Model.ComponentValue); + if (!component.Info.HasAdjustableValue) + return summary; + + var circuitState = !IsSensorPowered(component) + ? "connect VCC and GND" + : !IsSensorSignalConnected(component) + ? component.Info.Name == "HC-SR04 Distance" + ? "wire TRIG and ECHO to the controller" + : "wire the signal to a matching controller input" + : "powered · signal online"; + return $"{summary}\nCircuit: {circuitState}"; + } + + private static string BuildPhotoresistorSummary(ComponentInfo info, double value) + { + var reading = SensorSimulation.ReadPhotoresistor(value); + return $"{info.Parameters}\nDivider output {reading.Voltage:0.00} V · ADC {reading.AdcValue}/{reading.AdcMaximum}\nLDR ≈ {reading.SourceResistanceOhms / 1_000:0.#} kΩ"; + } + + private static string BuildPotentiometerSummary(ComponentInfo info, double value) + { + var reading = SensorSimulation.ReadPotentiometer(value); + return $"{info.Parameters}\nWiper output {reading.Voltage:0.00} V · ADC {reading.AdcValue}/{reading.AdcMaximum}"; + } + + private static string BuildUltrasonicSummary(ComponentInfo info, double value) + { + var reading = SensorSimulation.ReadHcSr04(value); + return $"{info.Parameters}\nEcho pulse {reading.EchoDurationMicroseconds:0} µs at 20 °C\nRound trip · {reading.SpeedOfSoundMetersPerSecond:0.0} m/s"; + } + + private static string BuildDht11Summary(ComponentInfo info, double value) + { + var reading = SensorSimulation.ReadDht11(value); + return $"{info.Parameters}\nReported {reading.TemperatureCelsius:0} °C · ±{reading.TemperatureAccuracyCelsius:0} °C\nDigital refresh ≥ {reading.MinimumSampleIntervalSeconds:0} s"; + } + + private static string BuildSensorChangeHint(ComponentInfo info, double value) => info.Name switch + { + "LDR Sensor" => $"Light updated. Analog input is {SensorSimulation.ReadPhotoresistor(value).AdcValue}/1023.", + "Potentiometer" => $"Wiper updated. Analog input is {SensorSimulation.ReadPotentiometer(value).Voltage:0.00} V.", + "HC-SR04 Distance" => $"Target updated. Echo pulse is {SensorSimulation.ReadHcSr04(value).EchoDurationMicroseconds:0} µs.", + "DHT11 Temperature" => $"Temperature updated. DHT11 reports {SensorSimulation.ReadDht11(value).TemperatureCelsius:0} °C at its next sample.", + _ => $"{info.ValueLabel} updated to {value:0.#}{info.ValueUnit}." + }; + + private static void UpdateSensorReading(EditorComponent component) + { + switch (component.Info.Name) + { + case "LDR Sensor": + component.Model.SetPinSignalValue("OUT", SensorSimulation.ReadPhotoresistor(component.Model.ComponentValue).Voltage); + break; + case "Potentiometer": + component.Model.SetPinSignalValue("WIPER", SensorSimulation.ReadPotentiometer(component.Model.ComponentValue).Voltage); + break; + case "HC-SR04 Distance": + component.Model.SetPinSignalValue("ECHO", SensorSimulation.ReadHcSr04(component.Model.ComponentValue).EchoDurationMicroseconds); + break; + case "DHT11 Temperature": + component.Model.SetPinSignalValue("DATA", SensorSimulation.ReadDht11(component.Model.ComponentValue).TemperatureCelsius); + break; + } + } private static void UpdateSwitchVisual(EditorComponent component) { @@ -1388,7 +1750,10 @@ private static EditorNode CreateEditorNode(string componentName) "LDR Sensor" or "Potentiometer" or "HC-SR04 Distance" or "DHT11 Temperature" => NodeKind.Sensor, _ => NodeKind.Electronic }; - var node = new EditorNode(componentName, kind); + var node = new EditorNode(componentName, kind) + { + ComponentValue = GetComponentInfo(componentName).DefaultValue + }; switch (componentName) { @@ -1399,6 +1764,8 @@ private static EditorNode CreateEditorNode(string componentName) case "STM32 Blue Pill": case "ATtiny85": node.AddTerminal("D2", PinDirection.Input, PinSignalType.Digital); + node.AddTerminal("A0", PinDirection.Input, PinSignalType.Analog); + node.AddTerminal("D7", PinDirection.Bidirectional, PinSignalType.Digital); node.AddTerminal("D13", PinDirection.Output, PinSignalType.Digital); node.AddTerminal("5V", PinDirection.Output, PinSignalType.Power); node.AddTerminal("GND", PinDirection.Passive, PinSignalType.Ground); @@ -1449,42 +1816,45 @@ private void SimulationTimer_Tick(object? sender, EventArgs e) { const double deltaSeconds = 0.05; _timeSeconds += deltaSeconds; - _blinkAccumulatorSeconds += deltaSeconds; TimeText.Text = $"{_timeSeconds:0.000} s"; var simulationContext = new SimulationContext(_timeSeconds); foreach (var component in _addedComponents.Values) component.Model.Step(simulationContext, deltaSeconds); var changed = false; - foreach (var (pinNumber, mode) in _sketchPinModes) + foreach (var (pinNumber, profile) in _sketchOutputProfiles) { var previous = _digitalPinStates.GetValueOrDefault(pinNumber); - var next = mode switch + var next = profile.Mode switch { DigitalOutputMode.High => true, DigitalOutputMode.Low => false, _ => previous }; - if (previous != next) + + if (profile.Mode == DigitalOutputMode.Blink) { - _digitalPinStates[pinNumber] = next; - changed = true; + var elapsed = _pinPhaseElapsedSeconds.GetValueOrDefault(pinNumber) + deltaSeconds; + var phaseDuration = previous ? profile.HighDurationSeconds : profile.LowDurationSeconds; + while (elapsed >= phaseDuration) + { + elapsed -= phaseDuration; + next = !next; + phaseDuration = next ? profile.HighDurationSeconds : profile.LowDurationSeconds; + } + _pinPhaseElapsedSeconds[pinNumber] = elapsed; } - } - if (_blinkAccumulatorSeconds >= _blinkPeriodSeconds) - { - _blinkAccumulatorSeconds %= _blinkPeriodSeconds; - foreach (var pinNumber in _sketchPinModes - .Where(pair => pair.Value == DigitalOutputMode.Blink) - .Select(pair => pair.Key) - .ToList()) + if (previous != next) { - _digitalPinStates[pinNumber] = !_digitalPinStates.GetValueOrDefault(pinNumber); + _digitalPinStates[pinNumber] = next; changed = true; } } + if (ApplyConditionalOutputs()) + changed = true; + if (changed) EvaluateCircuitState(); } @@ -1500,19 +1870,26 @@ private bool AnalyzeSketch() CodeEditorTextBox.Height = editorHeight; var analysis = ArduinoSketchProgram.Analyze(code); - _blinkPeriodSeconds = analysis.IntervalSeconds; - _sketchPinModes.Clear(); + _lastSketchAnalysis = analysis; + _sketchOutputProfiles.Clear(); + _conditionalOutputRules.Clear(); + _pinPhaseElapsedSeconds.Clear(); _digitalPinStates.Clear(); - foreach (var (pinNumber, mode) in analysis.Outputs) + _sketchInputPins.Clear(); + foreach (var (pinNumber, profile) in analysis.OutputProfiles) { - _sketchPinModes[pinNumber] = mode; - _digitalPinStates[pinNumber] = mode == DigitalOutputMode.High; + _sketchOutputProfiles[pinNumber] = profile; + _pinPhaseElapsedSeconds[pinNumber] = 0; + _digitalPinStates[pinNumber] = profile.InitialState; } + foreach (var pin in analysis.InputPins) + _sketchInputPins.Add(pin); + _conditionalOutputRules.AddRange(analysis.ConditionalOutputs); if (analysis.IsValid) { CodeStatusDot.Fill = Brush("#46D39A"); - CodeStatusText.Text = "Sketch ready"; + CodeStatusText.Text = analysis.Diagnostic; CodeStatusText.Foreground = Brush("#7DDBB8"); } else @@ -1521,14 +1898,55 @@ private bool AnalyzeSketch() CodeStatusText.Text = analysis.Diagnostic; CodeStatusText.Foreground = Brush("#FF9AAE"); } + UpdateCircuitAssistant(); return analysis.IsValid; } + private string BuildSimulationHint() + { + var activities = new List(); + var staticPins = _sketchOutputProfiles + .Where(pair => pair.Value.Mode is DigitalOutputMode.High or DigitalOutputMode.Low) + .Select(pair => $"D{pair.Key} {(pair.Value.Mode == DigitalOutputMode.High ? "HIGH" : "LOW")}"); + activities.AddRange(staticPins); + activities.AddRange(_sketchOutputProfiles + .Where(pair => pair.Value.Mode == DigitalOutputMode.Blink) + .Select(pair => $"D{pair.Key} {pair.Value.HighDurationSeconds:0.##}s HIGH / {pair.Value.LowDurationSeconds:0.##}s LOW")); + activities.AddRange(_conditionalOutputRules.Select(rule => + $"D{rule.OutputPin} follows {rule.Condition.ToDisplayString()}")); + if (_sketchInputPins.Count > 0) + activities.Add($"reading {string.Join(", ", _sketchInputPins.OrderBy(pin => pin))}"); + + return activities.Count == 0 + ? "Simulation is live." + : $"Sketch live · {string.Join(" · ", activities)}"; + } + private void CodeEditorTextBox_TextChanged(object? sender, TextChangedEventArgs e) { - AnalyzeSketch(); - if (_isSimulationRunning) + var isValid = AnalyzeSketch(); + if (!_isSimulationRunning) + return; + + if (isValid) + { EvaluateCircuitState(); + return; + } + + _simulationTimer.Stop(); + _isSimulationRunning = false; + RunButton.IsEnabled = true; + StopButton.IsEnabled = false; + SimulationPulse.Fill = Brush("#FF607D"); + SimulationStateText.Text = "CODE ERROR"; + SimulationStateText.Foreground = Brush("#FF8EA4"); + ArduinoStatus.Text = "Sketch paused"; + ArduinoStatus.Foreground = Brush("#FF9AAE"); + StatusText.Text = "Code issue"; + HintText.Text = "Simulation paused because the edited sketch is no longer valid."; + FooterText.Text = "SimForge 0.7.0 · Fix sketch diagnostics to continue"; + EvaluateCircuitState(); } private void StarterCircuitButton_Click(object? sender, RoutedEventArgs e) @@ -1573,7 +1991,7 @@ private void StarterCircuitButton_Click(object? sender, RoutedEventArgs e) UpdateWorkspaceUi(); HintText.Text = "Starter circuit ready. Press Run to simulate the blinking LED on pin D13."; TutorialText.Text = "The resistor limits LED current and the ground node completes the return path."; - FooterText.Text = "SimForge 0.4.0 · Starter circuit loaded"; + FooterText.Text = "SimForge 0.7.0 · Starter circuit loaded"; StatusText.Text = "Demo ready"; } @@ -1615,7 +2033,7 @@ private bool TryConfirmWorkspaceReplacement(WorkspaceReplacementAction action, B HintText.Text = action == WorkspaceReplacementAction.Clear ? "This removes every component and wire. Click Confirm clear within four seconds to continue." : "Loading the demo replaces the current workspace. Click Confirm load within four seconds to continue."; - FooterText.Text = "SimForge 0.4.0 · Waiting for confirmation"; + FooterText.Text = "SimForge 0.7.0 · Waiting for confirmation"; return false; } @@ -1637,7 +2055,7 @@ private void ResetWorkspaceReplacementConfirmation(bool expired = false) { StatusText.Text = _isSimulationRunning ? "Running" : "Ready"; HintText.Text = "Confirmation expired. Your workspace was left unchanged."; - FooterText.Text = "SimForge 0.4.0 · Workspace unchanged"; + FooterText.Text = "SimForge 0.7.0 · Workspace unchanged"; } } @@ -1665,7 +2083,7 @@ private void ClearWorkspace(bool announce) if (announce) { HintText.Text = "Workspace cleared. Add a component or load the starter circuit."; - FooterText.Text = "SimForge 0.4.0 · New empty circuit"; + FooterText.Text = "SimForge 0.7.0 · New empty circuit"; } } @@ -1678,7 +2096,7 @@ private void GraphCanvas_PointerPressed(object? sender, PointerPressedEventArgs ClearSelection(); ShowWorkspaceInspector(); CursorPositionText.Text = $"X {point.X:0} · Y {point.Y:0}"; - FooterText.Text = "SimForge 0.4.0 · Workspace selected"; + FooterText.Text = "SimForge 0.7.0 · Workspace selected"; StatusText.Text = _isSimulationRunning ? "Running" : "Ready"; } @@ -1741,12 +2159,12 @@ private static ComponentInfo GetComponentInfo(string name) => private static readonly IReadOnlyList ComponentCatalog = [ - new("Arduino Uno", "Microcontrollers", "UNO", "MCU", "ATmega328P development board with a familiar 5 V I/O platform.", "ATmega328P · 5 V", "D2, D13, 5V, GND", "Clock 16 MHz · Logic 5 V", "#78ACFF", "#162F50", "Use D13 as an output, D2 as an input, and connect the board to a complete circuit.", "DIGITAL"), - new("Arduino Nano", "Microcontrollers", "NANO", "MCU", "Compact ATmega328P board designed for breadboard projects.", "Compact AVR · 5 V", "D2, D13, 5V, GND", "Clock 16 MHz · Logic 5 V", "#78ACFF", "#162F50", "The Nano behaves like a compact Uno for this simulation.", "DIGITAL"), - new("ESP32 DevKit", "Microcontrollers", "ESP32", "MCU", "Dual-core wireless microcontroller with Wi-Fi and Bluetooth.", "Wi-Fi · Bluetooth · 3.3 V", "D2, D13, 5V, GND", "Clock 240 MHz · Logic 3.3 V", "#B09AFF", "#282047", "Respect 3.3 V logic levels when pairing the ESP32 with external devices.", "DIGITAL"), - new("Raspberry Pi Pico", "Microcontrollers", "PICO", "MCU", "RP2040 microcontroller board with two ARM Cortex-M0+ cores.", "RP2040 · dual core", "D2, D13, 5V, GND", "Clock 133 MHz · Logic 3.3 V", "#62D5C1", "#153A36", "Use the Pico for compact embedded control and sensor projects.", "DIGITAL"), - new("STM32 Blue Pill", "Microcontrollers", "STM", "MCU", "STM32F103 board for fast 32-bit embedded control.", "Cortex-M3 · 72 MHz", "D2, D13, 5V, GND", "Clock 72 MHz · Logic 3.3 V", "#68C7FF", "#163448", "Verify signal voltage compatibility before wiring 5 V modules.", "DIGITAL"), - new("ATtiny85", "Microcontrollers", "85", "MCU", "Minimal 8-bit AVR microcontroller for compact projects.", "Minimal 8-bit AVR", "D2, D13, 5V, GND", "Clock 8 MHz · Logic 5 V", "#F3CE84", "#382F1F", "The ATtiny85 is ideal when only a few I/O pins are required.", "DIGITAL"), + new("Arduino Uno", "Microcontrollers", "UNO", "MCU", "ATmega328P development board with a familiar 5 V I/O platform.", "ATmega328P · 5 V", "D2, A0, D7, D13, 5V, GND", "Clock 16 MHz · Logic 5 V", "#78ACFF", "#162F50", "Use D13 as an output, D2/D7 for digital sensors, and A0 for analog sensors.", "DIGITAL"), + new("Arduino Nano", "Microcontrollers", "NANO", "MCU", "Compact ATmega328P board designed for breadboard projects.", "Compact AVR · 5 V", "D2, A0, D7, D13, 5V, GND", "Clock 16 MHz · Logic 5 V", "#78ACFF", "#162F50", "The Nano exposes digital and analog input paths in this simulation.", "DIGITAL"), + new("ESP32 DevKit", "Microcontrollers", "ESP32", "MCU", "Dual-core wireless microcontroller with Wi-Fi and Bluetooth.", "Wi-Fi · Bluetooth · 3.3 V", "D2, A0, D7, D13, 5V, GND", "Clock 240 MHz · Logic 3.3 V", "#B09AFF", "#282047", "Respect 3.3 V logic levels when pairing the ESP32 with external devices.", "DIGITAL"), + new("Raspberry Pi Pico", "Microcontrollers", "PICO", "MCU", "RP2040 microcontroller board with two ARM Cortex-M0+ cores.", "RP2040 · dual core", "D2, A0, D7, D13, 5V, GND", "Clock 133 MHz · Logic 3.3 V", "#62D5C1", "#153A36", "Use A0 for analog sensors and D2/D7 for digital sensor data.", "DIGITAL"), + new("STM32 Blue Pill", "Microcontrollers", "STM", "MCU", "STM32F103 board for fast 32-bit embedded control.", "Cortex-M3 · 72 MHz", "D2, A0, D7, D13, 5V, GND", "Clock 72 MHz · Logic 3.3 V", "#68C7FF", "#163448", "Verify signal voltage compatibility before wiring 5 V modules.", "DIGITAL"), + new("ATtiny85", "Microcontrollers", "85", "MCU", "Minimal 8-bit AVR microcontroller for compact projects.", "Minimal 8-bit AVR", "D2, A0, D7, D13, 5V, GND", "Clock 8 MHz · Logic 5 V", "#F3CE84", "#382F1F", "Use the exposed digital and analog paths for compact sensor projects.", "DIGITAL"), new("LED", "Basic Electronics", "LED", "OUTPUT", "Light-emitting diode that converts electrical energy into visible light.", "Light-emitting diode", "Anode, Cathode", "Forward voltage 2.0 V · Current 20 mA", "#FF718B", "#3D1E28", "Always place a current-limiting resistor in series with an LED.", "POLARIZED"), new("Resistor", "Basic Electronics", "Ω", "PASSIVE", "Passive element that limits current and divides voltage.", "220 Ω · current limiter", "A, B", "Resistance 220 Ω · Power 0.25 W", "#F1C975", "#382F1F", "Use a resistor to protect LEDs and shape analog signals.", "ANALOG"), new("Capacitor", "Basic Electronics", "C", "PASSIVE", "Passive component that stores electrical charge.", "10 µF · charge storage", "A, B", "Capacitance 10 µF · Rating 16 V", "#75A9FF", "#1A304D", "Capacitors can smooth supply noise and create timing networks.", "ANALOG"), @@ -1754,10 +2172,10 @@ private static ComponentInfo GetComponentInfo(string name) => new("Button", "Switches", "PB", "SWITCH", "Momentary switch that closes a digital path when activated.", "Momentary contact", "IN, OUT", "State open", "#C1CEDC", "#29303A", "Click the control on the node to change its simulated contact state.", "DIGITAL"), new("Toggle Switch", "Switches", "TGL", "SWITCH", "Mechanical switch that maintains its open or closed state.", "Latching on / off", "IN, OUT", "State open", "#F2A65A", "#3A291E", "A closed switch conducts; an open switch breaks the simulated path.", "DIGITAL"), new("Slide Switch", "Switches", "S1", "SWITCH", "Two-position selector for routing a digital signal.", "Two-position selector", "IN, OUT", "Position open", "#F2D866", "#38331C", "Use slide switches to model persistent user input.", "DIGITAL"), - new("LDR Sensor", "Sensors & Inputs", "LUX", "SENSOR", "Photoresistor input whose output follows ambient light.", "Ambient light input", "VCC, OUT, GND", "Light level 0–100%", "#FFBE5C", "#3B2D1A", "Adjust the light value to test threshold-driven logic.", "ANALOG", true, "Light level", 0, 100, "%", 50, true), - new("Potentiometer", "Sensors & Inputs", "POT", "INPUT", "Variable resistor used as an adjustable analog voltage divider.", "Variable analog input", "VCC, WIPER, GND", "Position 0–100%", "#69C1FF", "#18334A", "Move the slider to simulate the wiper position.", "ANALOG", true, "Wiper position", 0, 100, "%", 50, true), - new("HC-SR04 Distance", "Sensors & Inputs", "CM", "SENSOR", "Ultrasonic ranging module for measuring nearby distance.", "Ultrasonic · 2–400 cm", "VCC, TRIG, ECHO, GND", "Range 2–400 cm · Frequency 40 kHz", "#5CE0C7", "#173936", "Adjust the target distance and observe your program response.", "DIGITAL", true, "Target distance", 2, 400, " cm", 100, false), - new("DHT11 Temperature", "Sensors & Inputs", "°C", "SENSOR", "Digital environmental sensor for temperature and humidity.", "Temperature · humidity", "VCC, DATA, GND", "Range 0–50 °C · 20–90% RH", "#FF7D7D", "#3D2022", "Adjust the temperature to exercise environmental control logic.", "DIGITAL", true, "Temperature", 0, 50, " °C", 30, true) + new("LDR Sensor", "Sensors & Inputs", "LUX", "SENSOR", "Non-linear photoresistor voltage divider whose output follows ambient light.", "10 kΩ divider · analog ADC", "VCC, OUT, GND", "LDR 1 kΩ–1 MΩ · 10-bit ADC", "#FFBE5C", "#3B2D1A", "Wire OUT to A0; use if (analogRead(A0) > 600) to drive an output.", "ANALOG", true, "Light level", 0, 100, "%", 50), + new("Potentiometer", "Sensors & Inputs", "POT", "INPUT", "10 kΩ voltage divider with a linear adjustable wiper output.", "0–5 V analog wiper", "VCC, WIPER, GND", "Position 0–100% · 10-bit ADC", "#69C1FF", "#18334A", "Wire WIPER to A0; compare analogRead(A0) in a simple if/else.", "ANALOG", true, "Wiper position", 0, 100, "%", 50), + new("HC-SR04 Distance", "Sensors & Inputs", "CM", "SENSOR", "Ultrasonic time-of-flight module with temperature-adjusted echo timing.", "40 kHz · 2–400 cm", "VCC, TRIG, ECHO, GND", "10 µs trigger · timed echo pulse", "#5CE0C7", "#173936", "Drive TRIG from D7, connect ECHO to D2, then read pulseIn(D2, HIGH).", "DIGITAL", true, "Target distance", 2, 400, " cm", 100), + new("DHT11 Temperature", "Sensors & Inputs", "°C", "SENSOR", "Quantized digital temperature and humidity sensor with a limited refresh rate.", "1 °C resolution · 2 s refresh", "VCC, DATA, GND", "0–50 °C · ±2 °C", "#FF7D7D", "#3D2022", "Connect DATA to D2; compare dht.readTemperature() in a simple if/else.", "DIGITAL", true, "Temperature", 0, 50, " °C", 24) ]; private sealed record ComponentInfo( @@ -1778,8 +2196,7 @@ private sealed record ComponentInfo( double ValueMin = 0, double ValueMax = 100, string ValueUnit = "", - double TriggerThreshold = 50, - bool TriggerAbove = true); + double DefaultValue = 50); private sealed class EditorComponent { diff --git a/SimForge/Program.cs b/SimForge/Program.cs index 3d4acc0..ae7cf6c 100644 --- a/SimForge/Program.cs +++ b/SimForge/Program.cs @@ -38,9 +38,6 @@ public static AppBuilder BuildAvaloniaApp() { RenderingMode = [AvaloniaNativeRenderingMode.Software] }) -#if DEBUG - .WithDeveloperTools() -#endif .WithInterFont() .LogToTrace(); } diff --git a/SimForge/SimForge.csproj b/SimForge/SimForge.csproj index 57a32d8..acacf79 100644 --- a/SimForge/SimForge.csproj +++ b/SimForge/SimForge.csproj @@ -4,7 +4,7 @@ net9.0 enable app.manifest - 0.4.0 + 0.7.0 true SimForge @@ -21,9 +21,5 @@ - - None - All - - - + + diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index 263079b..fe11990 100755 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -2,7 +2,7 @@ set -euo pipefail repo_dir="$(cd "$(dirname "$0")/.." && pwd)" -version="${1:-0.4.0}" +version="${1:-0.7.0}" output_dir="${2:-$repo_dir/artifacts}" package_name="SimForge-${version}-macOS-arm64" publish_dir="$(mktemp -d "${TMPDIR:-/tmp}/simforge-publish.XXXXXX")" From e11f2fb2863bf331263b9df7d63ccc08aa67d255 Mon Sep 17 00:00:00 2001 From: Kaan Orbay Date: Wed, 2 Sep 2026 00:03:09 +0300 Subject: [PATCH 2/2] Fix Arduino defines on Windows --- CHANGELOG.md | 2 +- .../ArduinoSketchProgramTests.cs | 19 +++++++++++++++++++ SimForge.Core/ArduinoSketchProgram.cs | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 264521e..5ac9f3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Added ordered, actionable diagnostics for missing controllers, sensor power, signal wiring, empty sketch pins, unused outputs, incomplete LED loops, and unsafe LED paths. - Added beginner-friendly explanations for common sketch diagnostics. - Added complete starter sketches for analog sensors, HC-SR04, and DHT11 circuits. -- Increased regression coverage to 51 tests. +- Increased regression coverage to 52 tests, including Windows CRLF sketch parsing. ## 0.6.0 - 2026-09-01 diff --git a/SimForge.Core.Tests/ArduinoSketchProgramTests.cs b/SimForge.Core.Tests/ArduinoSketchProgramTests.cs index da46e4c..6c29295 100644 --- a/SimForge.Core.Tests/ArduinoSketchProgramTests.cs +++ b/SimForge.Core.Tests/ArduinoSketchProgramTests.cs @@ -138,6 +138,25 @@ void loop() { Assert.Equal(0.25, program.OutputProfiles[12].LowDurationSeconds); } + [Fact] + public void Analyze_ResolvesDefinesWithWindowsLineEndings() + { + const string sketch = "#define DHT_PIN 7\r\n" + + "DHT dht(DHT_PIN, DHT11);\r\n" + + "void setup() { pinMode(13, OUTPUT); }\r\n" + + "void loop() {\r\n" + + " float temperature = dht.readTemperature();\r\n" + + " if (temperature >= 30) digitalWrite(13, HIGH);\r\n" + + " else digitalWrite(13, LOW);\r\n" + + "}"; + + var program = ArduinoSketchProgram.Analyze(sketch); + + Assert.True(program.IsValid, program.Diagnostic); + Assert.Equal("D7", Assert.Single(program.InputPins)); + Assert.Single(program.ConditionalOutputs); + } + [Fact] public void Analyze_PreservesAsymmetricBlinkTiming() { diff --git a/SimForge.Core/ArduinoSketchProgram.cs b/SimForge.Core/ArduinoSketchProgram.cs index b7c2845..f82e3c0 100644 --- a/SimForge.Core/ArduinoSketchProgram.cs +++ b/SimForge.Core/ArduinoSketchProgram.cs @@ -240,7 +240,7 @@ private static Dictionary ResolveIntegerConstants(string code) foreach (Match match in Regex.Matches( code, - @"(?m)^\s*#\s*define\s+(?[A-Za-z_]\w*)\s+(?[^\r\n]+)$")) + @"(?m)^[ \t]*#[ \t]*define[ \t]+(?[A-Za-z_]\w*)[ \t]+(?[^\r\n]+?)[ \t]*\r?$")) candidates.Add((match.Groups["name"].Value, match.Groups["value"].Value.Trim())); foreach (Match match in Regex.Matches(