Complete example: temperature sensor (driver + protocol)
The ISS example showed the smallest possible driver + protocol pair — here is its counterpart: a complete, compilable and working pair, a driver simulating a multi-channel temperature sensor and a protocol decoding its data into variables. The example deliberately exercises every member of the contract including the write path, and needs no hardware (nor internet):
- TempSensorDriver plays the device: every period it produces one text
line per channel (
ch1=23.47, the temperature drifts slowly) and hands the lines to its protocols. - TempSensorProtocol decodes the lines: a variable addresses a channel
with the
channel="ch1"commParam, the value is converted to the variable's type and the subscribers (graph, logger, scripts) are notified. - Writes: a variable with
direction="write"goes UI →Encode→ the transmitter injected by the driver (ApplyWriteAsync), which re-bases the channel to the written value — the graph shows the jump with the next sample and the drift continues from the new value.
Trying the example
- The installer already ships both plugins
(
Qenex.QSuite.Examples.TempSensorDriver.dllinDrivers,Qenex.QSuite.Examples.TempSensorProtocol.dllinProtocolsnext to QInsight.exe). If you build your own modified copy (projects below), replace these DLLs with it. - In Project Configuration add the Temperature Sensor (example)
driver —
periodMs=500;channels=2is pre-filled — and the Temperature Sensor Protocol (example) under it. The protocol names the driver inCompatibleDrivers(see below), so the pair only offers itself together. - Create variables, e.g.
Temp1(double),Temp2(double) andTempSet(double), and assign them to the protocol with commParams:Temp1:direction="read";channel="ch1"Temp2:direction="read";channel="ch2"TempSet:direction="write";channel="ch1"
- Start the runtime:
Temp1/Temp2flow into the graph. Write e.g.50intoTempSetthrough a control in Write Mode (Single-Signal or Watch Table, see Write to the device) — channel ch1 jumps to 50 °C. The control shows only values from the device, so the new temperature appears inTemp1, not as an echo inTempSet.
Projects
Two ordinary class-library projects; they reference only the QSuite base
projects (Driver, resp. Protocol + VariableEvents).
<!-- TempSensorDriver.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Qenex.QSuite.Examples.TempSensorDriver</AssemblyName>
<RootNamespace>Qenex.QSuite.Examples.TempSensorDriver</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Drivers\Driver\Driver.csproj" />
</ItemGroup>
</Project>
<!-- TempSensorProtocol.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Qenex.QSuite.Examples.TempSensorProtocol</AssemblyName>
<RootNamespace>Qenex.QSuite.Examples.TempSensorProtocol</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Protocols\Protocol\Protocol.csproj" />
<ProjectReference Include="..\..\Variables\VariableEvents\VariableEvents.csproj" />
</ItemGroup>
</Project>
A ready-made project with this plugin pair ships in the installer as
Qenex_SimulTempMeasure.qproj, described in the
Simulated thermometer example.
Driver: TempSensorDriver.cs
Worth noticing: ITransportSource<string> (the driver hands text lines to
its protocols), DefaultRawSettings (the settings template) and
SettingsParser in SetConfiguration (an unknown key is reported as a
warning), the state lifecycle, SetTransmitters (injecting the write path into the protocols)
and the delegation via IProtocolVariableCommandDriver — operator writes
go to the protocol's IProtocolVariableWriteProtocol, On Request reads
to IProtocolVariableReadProtocol. This protocol does not implement the
latter (the device only streams its values), so CanRequestRead returns
false and the Read button stays disabled for its variables.
using System.Globalization;
using System.Reflection;
using Qenex.QSuite.Common.CoreComm;
using Qenex.QSuite.Drivers.Driver;
using Qenex.QSuite.LogSystems.LogSystem;
using Qenex.QSuite.Protocols.Protocol;
using Qenex.QSuite.Specifications.Specification;
namespace Qenex.QSuite.Examples.TempSensorDriver;
/// <summary>
/// Example driver: simulates a temperature sensor device with a few channels. Every period it
/// produces one text line per channel ("ch1=23.47") and hands the lines to its protocols.
/// An operator write coming back through Send re-bases the channel's temperature, so the whole
/// read/write chain can be tried without any hardware.
/// </summary>
public class TempSensorDriver : DriverBase, IProtocolVariableCommandDriver, ITransportSource<string>
{
private int periodMs = 500;
private int channelCount = 2;
// The simulated device state: current temperature of every channel.
private readonly object channelLock = new();
private double[] channelTemps = [];
private readonly Random random = new();
private volatile bool exitRequested;
private CancellationTokenSource? runCts;
private Task? runTask;
public TempSensorDriver()
{
Specification = new SpecificationBase
{
Name = "TempSensorDriver",
Label = "Temperature Sensor (example)",
Description = "Example driver simulating a multi-channel temperature sensor.",
CreatedOn = new DateTime(2026, 7, 26),
Version = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(1, 0, 0),
Author = "Qenex",
Company = "QENEX Ltd."
};
}
#region Configuration
public override string DefaultRawSettings => "periodMs=500;channels=2";
private static readonly string[] KnownSettings = ["periodMs", "channels"];
public override void SetConfiguration()
{
// The shared parser splits "key=value;..." and warns about every key it does not know,
// the same way the built-in drivers do. A missing key keeps the default; an invalid
// value is reported and the default stays.
var settings = SettingsParser.Parse(RawSettings, KnownSettings, Logger, "Temperature sensor driver");
if (settings.TryGetValue("periodMs", out var periodText))
{
if (int.TryParse(periodText, out var parsedPeriod) && parsedPeriod > 0)
{
periodMs = parsedPeriod;
}
else
{
Logger?.Log(LogLevel.Warn, $"Temperature sensor driver: invalid periodMs '{periodText}', using {periodMs} ms.");
}
}
if (settings.TryGetValue("channels", out var channelsText))
{
if (int.TryParse(channelsText, out var parsedChannels))
{
channelCount = Math.Clamp(parsedChannels, 1, 16);
}
else
{
Logger?.Log(LogLevel.Warn, $"Temperature sensor driver: invalid channels '{channelsText}', using {channelCount}.");
}
}
}
#endregion
#region Driver control
public override async Task StartAsync(CancellationToken ct = default)
{
if (!IsEnabled)
{
SetState(CommunicationState.Disabled);
return;
}
if (State == CommunicationState.Running || runTask is { IsCompleted: false })
{
return;
}
SetState(CommunicationState.Starting);
exitRequested = false;
// Every channel starts at a slightly different temperature.
lock (channelLock)
{
channelTemps = Enumerable.Range(0, channelCount).Select(i => 20.0 + 2.0 * i).ToArray();
}
// Give write-capable protocols a TX path into this driver before they start.
SetTransmitters(ApplyWriteAsync);
foreach (var protocol in Protocols)
{
await protocol.StartAsync(ct);
}
runCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Task.Run: the loop must not inherit the caller's (UI) SynchronizationContext —
// a blocked dispatcher (window drag, busy UI) would stall the whole session.
// Capture the token now: a racing StopAsync may null runCts before the loop starts.
var runToken = runCts.Token;
runTask = Task.Run(() => RunLoopAsync(runToken));
SetState(CommunicationState.Running);
}
public override async Task StopAsync(CancellationToken ct = default)
{
SetState(CommunicationState.Stopping);
exitRequested = true;
if (runCts != null)
{
await runCts.CancelAsync();
}
if (runTask != null)
{
try
{
await runTask.WaitAsync(TimeSpan.FromSeconds(5), ct);
}
catch (OperationCanceledException)
{
}
catch (TimeoutException)
{
Logger?.Log(LogLevel.Warn, "Temp sensor: the sampling loop did not stop before timeout.");
}
}
foreach (var protocol in Protocols)
{
await protocol.StopAsync(ct);
}
SetTransmitters(null);
runCts?.Dispose();
runCts = null;
runTask = null;
SetState(CommunicationState.Stopped);
}
public override void Dispose()
{
runCts?.Dispose();
}
#endregion
#region Communication
// The protocol encoded an operator write into a "ch1=50" line — apply it to the device.
public override void Send<T>(T data)
{
if (data is string line)
{
ApplyWrite(line);
}
}
public override Task SendAsync<T>(T data, CancellationToken ct = default)
{
Send(data);
return Task.CompletedTask;
}
// Operator writes: delegated to the owning protocol (same pattern as the CAN and serial drivers).
public bool CanSendCommand(IProtocolVariable protocolVariable)
{
return Protocols
.OfType<IProtocolVariableWriteProtocol>()
.Any(protocol => protocol.CanWriteVariable(protocolVariable));
}
public async Task OnProtocolVariableCommandAsync(IProtocolVariable protocolVariable, CancellationToken ct = default)
{
foreach (var protocol in Protocols.OfType<IProtocolVariableWriteProtocol>())
{
if (!protocol.CanWriteVariable(protocolVariable))
{
continue;
}
await protocol.WriteVariableAsync(protocolVariable, ct);
return;
}
}
// On-request reads: the module wires read-requested notifications to this driver; the driver
// delegates to the owning protocol (IProtocolVariableReadProtocol), which reads the variable
// once over this driver's transport. Failures propagate so the requesting control sees them.
public bool CanRequestRead(IProtocolVariable protocolVariable)
{
return Protocols
.OfType<IProtocolVariableReadProtocol>()
.Any(protocol => protocol.CanReadVariable(protocolVariable));
}
public async Task OnProtocolVariableReadRequestAsync(IProtocolVariable protocolVariable, CancellationToken ct = default)
{
foreach (var protocol in Protocols.OfType<IProtocolVariableReadProtocol>())
{
if (!protocol.CanReadVariable(protocolVariable))
{
continue;
}
await protocol.ReadVariableAsync(protocolVariable, ct);
return;
}
throw new InvalidOperationException(
$"No protocol of this driver can read variable '{protocolVariable.Variable?.Name}' on request.");
}
#endregion
#region Simulated device
private async Task RunLoopAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested && !exitRequested)
{
var lines = new List<string>(channelCount);
lock (channelLock)
{
for (var i = 0; i < channelTemps.Length; i++)
{
// The temperature drifts slowly around its current base.
channelTemps[i] += 0.1 * (random.NextDouble() * 2.0 - 1.0);
lines.Add($"ch{i + 1}={channelTemps[i].ToString("F2", CultureInfo.InvariantCulture)}");
}
}
foreach (var protocol in Protocols)
{
if (protocol is ProtocolBase<string> textProtocol)
{
await textProtocol.AddReceivedDataToQueueAsync(lines, ct);
}
}
await Task.Delay(periodMs, ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested || exitRequested)
{
// Normal stop.
}
catch (Exception e)
{
Logger?.Log(LogLevel.Error, $"Temp sensor loop failed: {e.Message}");
SetState(CommunicationState.Faulted, e.Message);
}
}
private Task ApplyWriteAsync(string line, CancellationToken ct)
{
ApplyWrite(line);
return Task.CompletedTask;
}
private void ApplyWrite(string line)
{
var pair = line.Split('=', 2, StringSplitOptions.TrimEntries);
if (pair.Length != 2
|| !pair[0].StartsWith("ch", StringComparison.OrdinalIgnoreCase)
|| !int.TryParse(pair[0][2..], out var channelNumber)
|| !double.TryParse(pair[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
{
Logger?.Log(LogLevel.Warn, $"Temp sensor: cannot apply write '{line}'.");
return;
}
lock (channelLock)
{
var index = channelNumber - 1;
if (index < 0 || index >= channelTemps.Length)
{
Logger?.Log(LogLevel.Warn, $"Temp sensor: channel '{pair[0]}' does not exist.");
return;
}
channelTemps[index] = value;
}
Logger?.Log(LogLevel.Info, $"Temp sensor: channel {pair[0]} re-based to {pair[1]}.");
}
private void SetTransmitters(Func<string, CancellationToken, Task>? transmitter)
{
foreach (var protocol in Protocols)
{
if (protocol is ITransportProtocol<string> transportProtocol)
{
transportProtocol.SetTransmitter(transmitter);
}
}
}
#endregion
}
Protocol: TempSensorProtocol.cs
Worth noticing: CreateDefaultCommParam (the commParam template carried by
the plugin), the received-data queue (System.Threading.Channels),
Decode/Encode and the write interfaces ITransportProtocol<string>
IProtocolVariableWriteProtocol.
using System.Globalization;
using System.Reflection;
using System.Threading.Channels;
using Qenex.QSuite.Common.CoreComm;
using Qenex.QSuite.LogSystems.LogSystem;
using Qenex.QSuite.Protocols.Protocol;
using Qenex.QSuite.Specifications.Specification;
using Qenex.QSuite.Variables.QVariables;
using Qenex.QSuite.Variables.QVariables.Values;
using Qenex.QSuite.Variables.VariableEvents;
namespace Qenex.QSuite.Examples.TempSensorProtocol;
/// <summary>
/// Example protocol: decodes "ch1=23.47" text lines from the TempSensorDriver into variables
/// addressed by the "channel" commParam, and encodes operator writes back into the same lines.
/// The generic parameter (string) is what pairs the protocol with a text-line driver.
/// </summary>
public class TempSensorProtocol : ProtocolBase<string>, ITransportProtocol<string>, IProtocolVariableWriteProtocol
{
// Received lines waiting for processing; the channel keeps the consumer loop cancelable.
private Channel<string>? receivedLines;
private volatile Func<string, CancellationToken, Task>? transmitter;
private volatile bool exitRequested;
private CancellationTokenSource? runCts;
private Task? runTask;
public TempSensorProtocol()
{
Specification = new SpecificationBase
{
Name = "TempSensorProtocol",
Label = "Temperature Sensor Protocol (example)",
Description = "Example protocol decoding text lines of the simulated temperature sensor.",
CreatedOn = new DateTime(2026, 7, 26),
Version = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(1, 0, 0),
Author = "Qenex",
Company = "QENEX Ltd."
};
}
// The transport type (string) alone cannot tell text-line drivers apart, and this
// protocol only understands the "chN=value" sensor lines, so it narrows itself to its driver.
public override IReadOnlyList<string> CompatibleDrivers => ["TempSensorDriver"];
#region Configuration
// The protocol has no protocol-level settings; everything is per-variable commParams.
// The shared parser still runs so that a key typed into the protocol settings is reported
// as unknown instead of being ignored silently.
public override void SetConfiguration()
{
SettingsParser.Parse(RawSettings, [], Logger, "Temperature sensor protocol");
}
public override string CreateDefaultCommParam(IVariableBase variable, IEnumerable<IVarEvent> variableEvents)
{
return "direction=\"read\";channel=\"ch1\"";
}
#endregion
#region Protocol variables
public override IProtocolVariable? CreateProtocolVariable(IVariableBase variable, string commParams, bool isCommunicated)
{
try
{
return new ProtocolVariable
{
Variable = variable,
IsCommunicated = isCommunicated,
ProtocolVariableSpecification = TempSensorVariableSpecification.Create(commParams)
};
}
catch (Exception e)
{
Logger?.Log(LogLevel.Warn, $"Protocol variable specification for variable {variable.Name} could not be created ({e.Message}).");
return null;
}
}
public override IProtocolVariable? CreateProtocolVariable(IVariableBase variable, IVarEvent variableEvent, string id)
{
return CreateProtocolVariable(variable, $"direction=\"read\";channel=\"{id}\"", true);
}
public override IProtocolVariable? CreateProtocolVariable(IVariableBase variable, IEnumerable<IVarEvent> variableEvents,
string commParams, bool isCommunicated)
{
// The data arrive whenever the device sends them, so variable events are not used.
return CreateProtocolVariable(variable, commParams, isCommunicated);
}
#endregion
#region Protocol control
public override Task StartAsync(CancellationToken ct = default)
{
if (!IsEnabled)
{
SetState(CommunicationState.Disabled);
return Task.CompletedTask;
}
if (State == CommunicationState.Running || runTask is { IsCompleted: false })
{
return Task.CompletedTask;
}
SetState(CommunicationState.Starting);
exitRequested = false;
receivedLines = Channel.CreateUnbounded<string>();
runCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Task.Run: the loop must not inherit the caller's (UI) SynchronizationContext —
// a blocked dispatcher (window drag, busy UI) would stall the whole session.
// Capture the token now: a racing StopAsync may null runCts before the loop starts.
var runToken = runCts.Token;
runTask = Task.Run(() => RunLoopAsync(runToken));
SetState(CommunicationState.Running);
return Task.CompletedTask;
}
public override async Task StopAsync(CancellationToken ct = default)
{
SetState(CommunicationState.Stopping);
exitRequested = true;
receivedLines?.Writer.TryComplete();
if (runCts != null)
{
await runCts.CancelAsync();
}
if (runTask != null)
{
try
{
await runTask.WaitAsync(TimeSpan.FromSeconds(5), ct);
}
catch (OperationCanceledException)
{
}
catch (TimeoutException)
{
Logger?.Log(LogLevel.Warn, "Temp sensor protocol did not stop before timeout.");
}
}
runCts?.Dispose();
runCts = null;
runTask = null;
SetState(CommunicationState.Stopped);
}
public override void Dispose()
{
runCts?.Dispose();
}
#endregion
#region Received data
public override Task AddReceivedDataToQueueAsync(IEnumerable<string> data, CancellationToken ct = default)
{
foreach (var line in data)
{
receivedLines?.Writer.TryWrite(line);
}
return Task.CompletedTask;
}
private async Task RunLoopAsync(CancellationToken ct)
{
try
{
await foreach (var line in receivedLines!.Reader.ReadAllAsync(ct))
{
if (exitRequested)
{
break;
}
await ProcessReceivedDataAsync([line], ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested || exitRequested)
{
// Normal stop.
}
catch (Exception e)
{
Logger?.Log(LogLevel.Error, $"Temp sensor protocol loop failed: {e.Message}");
SetState(CommunicationState.Faulted, e.Message);
}
}
protected override void ProcessReceivedData(IEnumerable<string> data)
{
foreach (var protocolVariable in Decode(data))
{
protocolVariable.NotifyValueChanged();
}
}
protected override async Task ProcessReceivedDataAsync(IEnumerable<string> data, CancellationToken ct = default)
{
var notifyTasks = Decode(data).Select(protocolVariable => protocolVariable.NotifyValueChangedAsync());
await Task.WhenAll(notifyTasks);
}
#endregion
#region Encoding and decoding
protected override IEnumerable<IProtocolVariable> Decode(IEnumerable<string> data)
{
var updated = new List<IProtocolVariable>();
foreach (var line in data)
{
var pair = line.Split('=', 2, StringSplitOptions.TrimEntries);
if (pair.Length != 2
|| !double.TryParse(pair[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
{
Logger?.Log(LogLevel.Warn, $"Temp sensor protocol: cannot decode line '{line}'.");
continue;
}
foreach (var protocolVariable in Variables)
{
if (!protocolVariable.IsCommunicated
|| protocolVariable.ProtocolVariableSpecification is not TempSensorVariableSpecification spec
|| spec.Direction != CommDirection.Read
|| !spec.Channel.Equals(pair[0], StringComparison.OrdinalIgnoreCase)
|| protocolVariable.Variable is not ScalarVariable scalarVariable)
{
continue;
}
if (!TrySetValue(scalarVariable, value))
{
Logger?.Log(LogLevel.Warn, $"Temp sensor protocol: variable '{scalarVariable.Name}' has an unsupported value type.");
continue;
}
scalarVariable.Timestamp = DateTime.UtcNow;
updated.Add(protocolVariable);
}
}
return updated;
}
protected override IEnumerable<string> Encode(IEnumerable<IProtocolVariable> protocolVariables)
{
foreach (var protocolVariable in protocolVariables)
{
if (protocolVariable.ProtocolVariableSpecification is not TempSensorVariableSpecification spec
|| protocolVariable.Variable is not ScalarVariable scalarVariable)
{
continue;
}
var value = Convert.ToDouble(scalarVariable.Values.GetValue(), CultureInfo.InvariantCulture);
yield return $"{spec.Channel}={value.ToString("F2", CultureInfo.InvariantCulture)}";
}
}
// Values<T>.SetValue does not convert, so the received double is converted to the
// variable's type here; the example supports the common numeric types.
private static bool TrySetValue(ScalarVariable scalarVariable, double value)
{
switch (scalarVariable.Values)
{
case Values<double> doubleValues:
doubleValues.Value = value;
return true;
case Values<float> floatValues:
floatValues.Value = (float)value;
return true;
case Values<int> intValues:
intValues.Value = (int)Math.Round(value);
return true;
default:
return false;
}
}
#endregion
#region Operator writes
public void SetTransmitter(Func<string, CancellationToken, Task>? byteTransmitter)
{
transmitter = byteTransmitter;
}
public bool CanWriteVariable(IProtocolVariable protocolVariable)
{
return Variables.Contains(protocolVariable)
&& protocolVariable.ProtocolVariableSpecification is TempSensorVariableSpecification { Direction: CommDirection.Write };
}
public async Task WriteVariableAsync(IProtocolVariable protocolVariable, CancellationToken ct = default)
{
var currentTransmitter = transmitter
?? throw new InvalidOperationException("Transport is not available (no transmitter injected).");
foreach (var line in Encode([protocolVariable]))
{
await currentTransmitter(line, ct);
}
}
#endregion
}
Variable specification: TempSensorVariableSpecification.cs
The properties match the commParam keys 1:1 (channel, direction), so
the inherited ToCommParam() serialization works without an override.
using Qenex.QSuite.Protocols.Protocol;
namespace Qenex.QSuite.Examples.TempSensorProtocol;
/// <summary>
/// Addressing of one variable: which device channel it reads (or writes) and in which direction.
/// The properties match the commParam keys 1:1 ("channel", "direction"), so the inherited
/// reflection-based ToCommParam() round-trips the specification without an override.
/// </summary>
public class TempSensorVariableSpecification : ProtVariableSpecification
{
public TempSensorVariableSpecification()
{
Name = "TempSensorVariableSpecification";
}
/// <summary>Channel key as sent by the device, e.g. "ch1".</summary>
public string Channel { get; set; } = string.Empty;
public CommDirection Direction { get; set; } = CommDirection.Read;
public static TempSensorVariableSpecification Create(string commParams)
{
var parameters = commParams
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(parameter => parameter.Split('=', 2))
.Where(parts => parts.Length == 2)
.ToDictionary(parts => parts[0].Trim(), parts => parts[1].Trim().Trim('"'), StringComparer.OrdinalIgnoreCase);
if (!parameters.TryGetValue("channel", out var channel) || string.IsNullOrWhiteSpace(channel))
{
throw new ArgumentException("Missing mandatory commParam 'channel'.");
}
var direction = CommDirection.Read;
if (parameters.TryGetValue("direction", out var directionText) && !string.IsNullOrWhiteSpace(directionText))
{
direction = Enum.Parse<CommDirection>(directionText, ignoreCase: true);
}
return new TempSensorVariableSpecification
{
Channel = channel,
Direction = direction
};
}
}
How the members work together
| Step | Who | Members |
|---|---|---|
| Adding the driver/protocol in the UI | plugin | DefaultRawSettings, CreateDefaultCommParam |
| Assigning a variable | protocol | CreateProtocolVariable → the specification's Create |
| Runtime start | driver | StartAsync → SetTransmitters → start of the protocols |
| Reading data | driver → protocol | driver loop → AddReceivedDataToQueueAsync → ProcessReceivedDataAsync → Decode → NotifyValueChangedAsync |
| Write from the UI | QInsight → driver → protocol → driver | CanWriteVariable (Write Mode offered) → CanSendCommand → OnProtocolVariableCommandAsync → WriteVariableAsync → Encode → transmitter (ApplyWriteAsync) |
| Stop | driver | StopAsync (loop cancellation, protocol stop, transmitter disconnect) |
Next
Back to Custom driver and Custom protocol.

