Services Reference¶
The InstrumentControl.Core library exposes four services that the application and plugins rely on.
VisaService¶
File: src/InstrumentControl.Core/Services/VisaService.cs
Provides access to the NI-VISA library via P/Invoke and handles the simulation mode fallback.
Key Methods¶
public class VisaService
{
// Returns true when NI-VISA DLL was found and loaded successfully
public bool IsVisa { get; }
// Scan all VISA interfaces for connected resources
public IReadOnlyList<string> FindResources();
// Open a session to a specific resource
// Returns VisaConnectionProvider (real hardware) or SimulatedConnectionProvider (no VISA)
public IConnectionProvider OpenSession(string resourceName);
}
Simulation Mode¶
If the NI-VISA DLL (visa32.dll or visa64.dll) is not found, VisaService activates simulation mode:
IsSimulationModereturnstrueFindResources()returns a set ofSIM::resource strings, one per supported instrument typeOpenSimulated(resourceName)returns aSimulatedConnectionProvider- The status bar displays
VISA: Simulation
SimulatedConnectionProvider¶
SimulatedConnectionProvider is a stateful SCPI / protocol simulator. It parses every WriteAsync call to track instrument state, then returns context-appropriate responses to QueryAsync / ReadAsync.
State tracked across Write calls:
| Write command | State updated |
|---|---|
CONF:VOLT:DC, CONF:RES, … |
Active DMM function (_confFunc) |
SAMP:COUN N |
Burst sample count |
ROUT:SCAN (@...) |
Number of channels to return on next FETCH? |
VOLT x |
Power supply voltage set-point |
CURR x |
Power supply current limit |
OUTP ON/OFF |
Power supply output state |
VOLT:PROT x, CURR:PROT:LEV x |
OVP / OCP levels |
a0 x (CTS) |
Chamber temperature set-point + timestamp reset |
u0 x / d0 x (CTS) |
Ramp-up / ramp-down rate (K/min) |
s1 1 / s1 0 (CTS) |
Chamber running state |
TIM:SCAL x |
Oscilloscope timebase |
CHANn:SCAL x |
Oscilloscope channel scale |
Selected query responses:
| Query | Response |
|---|---|
*IDN? |
SIMULATED,INSTRUMENT,SIM001,1.0 |
READ? / FETCH? |
Value(s) matching last CONF: function, Gaussian noise applied |
MEAS:VOLT? |
PSU set-point ± 2 mV (or 0 if output OFF) |
MEAS:CURR? |
≈ 8 % of current limit ± 1 mA (or 0 if output OFF) |
CHANn:DATA? |
1 000-point 1 kHz sine wave ASCII |
A0 (CTS) |
A0 {actual} {setpoint} — temperature ramps in real time |
C (CTS) |
C 2.10;1.20;001; (firmware identification) |
All noise is generated with the Box-Muller Gaussian transform (not uniform NextDouble()).
Responses can be overridden for individual commands via SetResponse(command, value).
Disconnect / Reconnect¶
VisaConnectionProvider.CloseAsync() releases the native session with viClose. OpenAsync() is reconnect-aware: when called on a provider whose session was previously closed, it re-acquires a fresh session via viOpen (re-applying the timeout) instead of reusing the dead handle. This is what lets a driver's ReconnectAsync() restore a connection to the same resource after a front-panel Disconnect — see Front Panel → Connection toolbar. The simulated and serial providers reconnect the same way (toggling IsOpen / reopening the COM port).
See Simulation Mode in the User Guide for the full table of simulated values.
P/Invoke Bindings¶
VisaService wraps the following NI-VISA C API functions:
| NI-VISA function | C# binding | Purpose |
|---|---|---|
viOpen |
VisaNative.viOpen |
Open a resource session |
viClose |
VisaNative.viClose |
Close a session |
viWrite |
VisaNative.viWrite |
Write ASCII string |
viRead |
VisaNative.viRead |
Read ASCII response |
viBufWrite |
VisaNative.viBufWrite |
Write raw bytes |
viBufRead |
VisaNative.viBufRead |
Read raw bytes |
viSetAttribute |
VisaNative.viSetAttribute |
Set VISA attributes (timeout, etc.) |
viFindRsrc |
VisaNative.viFindRsrc |
Find resources matching a pattern |
viFindNext |
VisaNative.viFindNext |
Iterate resource list |
PluginLoader¶
File: src/InstrumentControl.Core/Services/PluginLoader.cs
Discovers and instantiates instrument driver plugins at startup.
Algorithm¶
public class PluginLoader
{
// Scan a directory for DLL files and instantiate all IInstrumentDriver implementations
public IReadOnlyList<IInstrumentDriver> LoadPlugins(string pluginsDirectory)
{
var drivers = new List<IInstrumentDriver>();
foreach (var dllPath in Directory.GetFiles(pluginsDirectory, "*.dll"))
{
var assembly = Assembly.LoadFrom(dllPath);
foreach (var type in assembly.GetTypes())
{
if (!typeof(IInstrumentDriver).IsAssignableFrom(type)) continue;
if (type.IsAbstract || type.IsInterface) continue;
if (type.GetConstructor(Type.EmptyTypes) is null) continue;
var instance = (IInstrumentDriver)Activator.CreateInstance(type)!;
drivers.Add(instance);
// Side effect: loading the assembly triggers static constructors,
// which register blocks in BlockRegistry.
}
}
return drivers;
}
}
Block Registration Side Effect¶
When Assembly.LoadFrom() loads a plugin DLL, the .NET runtime triggers static constructors on all types that have them. Each block class has a static constructor that calls BlockRegistry.Register(...). This is how blocks become available without any explicit registration call.
No MEF Required¶
The [Export(typeof(IInstrumentDriver))] attribute seen in some drivers is left over from an earlier MEF-based approach. It has no effect on the current reflection-based loader. Do not use MEF APIs — they are not referenced.
SequenceEngine¶
File: src/InstrumentControl.Core/Services/SequenceEngine.cs
Executes a sequence graph by walking the block chain.
State Machine¶
Key Members¶
public class SequenceEngine
{
public SequenceState State { get; }
// Start execution; returns when the sequence finishes or is cancelled
public Task RunAsync(
SequenceDefinition sequence,
Dictionary<string, IInstrumentDriver> instruments,
CancellationToken ct = default);
// Pause execution after current block completes
public void Pause();
// Resume after a Pause
public void Resume();
// Events
public event EventHandler<string> BlockStarted; // BlockId
public event EventHandler<string> BlockCompleted; // BlockId
public event EventHandler<string> LogMessage;
public event EventHandler<MeasurementResult> MeasurementReceived;
public event EventHandler<SequenceState> StateChanged;
}
Execution Loop¶
// Simplified pseudocode
var ctx = new SequenceContext(instruments, cancellationToken);
var current = FindStartBlock(sequence);
int iterations = 0;
while (current is not null)
{
if (ct.IsCancellationRequested) break;
await ctx.WaitIfPausedAsync(); // suspends here until Resume() (also honored inside LoopBlock)
StateChanged?.Invoke(this, SequenceState.Running);
BlockStarted?.Invoke(this, current.BlockId);
var result = await current.ExecuteAsync(ctx);
BlockCompleted?.Invoke(this, current.BlockId);
if (!result.Success)
{
LogMessage?.Invoke(this, $"ERROR in {current.DisplayName}: {result.ErrorMessage}");
// Continue or abort based on result.NextBlockId
}
current = result.NextBlockId is not null
? ctx.AllBlocks[result.NextBlockId]
: null;
if (++iterations > 100_000)
throw new InvalidOperationException("Sequence exceeded 100,000 iterations. Possible infinite loop.");
}
Pause / Resume¶
Pause is implemented as a shared pause gate on SequenceContext, backed by a TaskCompletionSource. SequenceEngine.Pause() / Resume() call context.SetPaused(true/false); any code with access to the context can suspend by awaiting context.WaitIfPausedAsync().
The gate is awaited in two places, which is what makes pause work inside loops:
- the engine's top-level execution loop, before each block, and
- inside
LoopBlock— on every iteration and before each block of the loop body.
Because of this, pressing Pause during a long-running loop halts within one iteration instead of waiting for the whole loop to finish. WaitIfPausedAsync() also observes the CancellationToken, so Stop works while paused (the await throws OperationCanceledException).
This approach uses zero CPU while paused (no polling).
BlockRegistry¶
A static dictionary inside SequenceEngine:
public static class BlockRegistry
{
private static readonly Dictionary<string, Func<ISequenceBlock>> _factories = new();
public static void Register(string blockType, Func<ISequenceBlock> factory)
=> _factories[blockType] = factory;
public static ISequenceBlock Create(string blockType)
=> _factories.TryGetValue(blockType, out var f)
? f()
: throw new KeyNotFoundException($"Unknown block type: {blockType}");
// Returns one instance of each block registered by the given assembly
public static IEnumerable<ISequenceBlock> GetBlocks(Assembly assembly)
=> _factories.Values
.Select(f => f())
.Where(b => b.GetType().Assembly == assembly);
}
DataManager¶
File: src/InstrumentControl.Core/Services/DataManager.cs
Thread-safe storage for measurement results collected during a session.
public class DataManager
{
// Add a result (thread-safe; can be called from any thread)
public void AddResult(MeasurementResult result);
// Retrieve all results, optionally filtered by parameter name
public IReadOnlyList<MeasurementResult> GetResults(string? parameterFilter = null);
// Get distinct parameter names across all results
public IReadOnlyList<string> GetParameterNames();
// Export to CSV file
// If append=true, rows are added to an existing file instead of overwriting
public void ExportToCsv(string filePath, bool append = false, string? parameterFilter = null);
// Remove all stored results
public void Clear();
// Fired on the thread that called AddResult, then marshalled to UI thread
public event EventHandler<MeasurementResult> ResultAdded;
}
Thread Safety¶
All access to _results is guarded by lock (_results). ResultAdded is marshalled to the WPF dispatcher thread before firing so that ViewModels can update ObservableCollection properties without cross-thread exceptions.
CSV Format¶
Timestamp,Instrument,Channel,Parameter,Value,Unit,Function
2026-06-16T10:23:45.123,HP34401A,,DCV,5.001234,V,DCV
2026-06-16T10:23:46.045,HP34401A,,DCV,5.002019,V,DCV
2026-06-16T10:23:47.001,CTSChamber,,Temperature,23.500000,°C,TEMP
Channelis empty for single-channel instruments- All timestamps are ISO 8601 with millisecond precision
Valueuses invariant culture (.decimal separator) for CSV portability