Kompletní ukázka: teploměr (driver + protokol)

Ukázka ISS předvedla nejmenší možnou dvojici driver + protokol — zde je její protipól: kompletní, přeložitelná a funkční dvojice, driver simulující vícekanálový teploměr a protokol, který jeho data dekóduje do proměnných. Ukázka záměrně využívá všechny členy kontraktu včetně zápisové cesty, a přitom nepotřebuje žádný hardware (ani internet):

  • TempSensorDriver zastupuje zařízení: v každé periodě vytvoří pro každý kanál textový řádek ch1=23.47 (teplota pomalu kolísá) a předá ho protokolům.
  • TempSensorProtocol řádky dekóduje: proměnná adresuje kanál commParamem channel="ch1", hodnota se převede na typ proměnné a notifikují se odběratelé (graf, logger, skripty).
  • Zápis: proměnná s direction="write" projde cestou UI → Encode → transmitter vložený driverem (ApplyWriteAsync), který kanál „přeladí“ na zapsanou hodnotu — v grafu je s dalším vzorkem patrná skoková změna a další kolísání kolem nové hodnoty.

Jak si ukázku vyzkoušet

  1. Oba pluginy už dodává instalátor (Qenex.QSuite.Examples.TempSensorDriver.dll ve složce Drivers, Qenex.QSuite.Examples.TempSensorProtocol.dll ve složce Protocols vedle QInsight.exe). Přeložíte-li si vlastní upravenou kopii (projekty níže), nahraďte jí tyto DLL.
  2. V Project Configuration přidejte driver Temperature Sensor (example) — předvyplní se periodMs=500;channels=2 — a k němu protokol Temperature Sensor Protocol (example). Protokol svůj driver jmenuje v CompatibleDrivers (viz níže), takže dvojice se nabízí jen spolu.
  3. Vytvořte proměnné, např. Temp1 (double), Temp2 (double) a TempSet (double), a přiřaďte je protokolu s commParams:
    • Temp1: direction="read";channel="ch1"
    • Temp2: direction="read";channel="ch2"
    • TempSet: direction="write";channel="ch1"
  4. Spusťte runtime: hodnoty Temp1/Temp2 přicházejí do grafu. Zapište přes control ve Write Mode (Single-Signal nebo Watch Table, viz Zápis do zařízení) do TempSet například 50 — teplota kanálu ch1 se změní na 50 °C. Control ukazuje jen hodnoty ze zařízení, takže novou teplotu uvidíte v Temp1, ne jako ozvěnu v TempSet.

Projekty

Dva běžné class-library projekty; referencují jen základní projekty QSuite (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>

Hotový projekt s touto dvojicí pluginů je v instalátoru jako Qenex_SimulTempMeasure.qproj, popsaný v příkladu Simulovaný teploměr.

Driver: TempSensorDriver.cs

Za povšimnutí: ITransportSource<string> (driver předává protokolům textové řádky), DefaultRawSettings (šablona nastavení) a SettingsParser v SetConfiguration (neznámý klíč se hlásí varováním), životní cyklus se stavy, SetTransmitters (injektáž zápisové cesty do protokolů) a delegace přes IProtocolVariableCommandDriver — zápis operátora míří na IProtocolVariableWriteProtocol protokolu, čtení On Request na IProtocolVariableReadProtocol. To tento protokol neimplementuje (zařízení hodnoty jen posílá), takže CanRequestRead vrací false a tlačítko Read zůstane pro jeho proměnné neaktivní.

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
}

Protokol: TempSensorProtocol.cs

Za povšimnutí: CreateDefaultCommParam (šablona commParams z pluginu), fronta přijatých dat (System.Threading.Channels), Decode/Encode a zápisová rozhraní 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
}

Specifikace proměnné: TempSensorVariableSpecification.cs

Properties odpovídají klíčům commParams 1:1 (channel, direction), takže zděděná serializace ToCommParam() funguje bez přepsání.

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
        };
    }
}

Jak spolu členy souvisí

KrokKdoČleny
Přidání driveru/protokolu v UIpluginDefaultRawSettings, CreateDefaultCommParam
Přiřazení proměnnéprotokolCreateProtocolVariable → Create specifikace
Start runtimedriverStartAsync → SetTransmitters → start protokolů
Čtení datdriver → protokolsmyčka driveru → AddReceivedDataToQueueAsync → ProcessReceivedDataAsync → Decode → NotifyValueChangedAsync
Zápis z UIQInsight → driver → protokol → driverCanWriteVariable (nabídka Write Mode) → CanSendCommand → OnProtocolVariableCommandAsync → WriteVariableAsync → Encode → transmitter (ApplyWriteAsync)
StopdriverStopAsync (zrušení smyčky, stop protokolů, odpojení transmitteru)

Dále

Zpět na Vlastní driver a Vlastní protokol.