Complete example: ISS position (driver + protocol)

The previous chapters showed skeletons — here is the smallest genuinely working driver + protocol pair you can write. And although the whole thing fits on a couple of screens, it works with real live data: once a second it asks a public REST API for the current position of the International Space Station and feeds four variables — the latitude, longitude, altitude and velocity of an object flying over your head at roughly 27,600 km/h.

The division of labour is exactly the one every QInsight driver and protocol uses:

  • IssDriver does one thing only: every period it downloads one JSON response with HttpClient and hands it to its protocols as-is. It knows nothing about the content — from its point of view it is just text.
  • IssJsonProtocol takes the text, parses it as JSON and distributes the values to variables. Each variable claims its field with the path="latitude" commParam, so one response fills all the variables at once.

The ISS Live project running — latitude/longitude graph, altitude gauge, current valuesThe ISS Live project running — latitude/longitude graph, altitude gauge, current values

The data has a pleasant property on top: it is not random. The latitude draws a sine wave with the period of one orbit (~92 minutes), the longitude a sawtooth — the station keeps flying "straight" while the Earth rotates underneath, so every orbit starts a bit further west. Let the graph run for a few minutes and the shapes start to show.

Where the data comes from

The example uses the wheretheiss.at API — free, no registration, no key. A single GET request:

https://api.wheretheiss.at/v1/satellites/25544

returns one flat JSON object (25544 is the ISS catalog number):

{
  "name": "iss",
  "latitude": -29.06,
  "longitude": 20.04,
  "altitude": 423.31,
  "velocity": 27575.13,
  "visibility": "daylight",
  ...
}

The operator asks for at most ~1 request per second — which is why the driver refuses a faster period (see SetConfiguration below). Running the example naturally requires an internet connection.

Trying the example

  1. Build both projects (below) and copy the DLLs into the Drivers and Protocols folders next to QInsight.exe.
  2. In Project Configuration add the ISS Position (example) driver — periodMs=1000 is pre-filled — and the ISS JSON Protocol (example) under it. The protocol names the driver in CompatibleDrivers (see below), so under any other driver it is greyed out — and vice versa.
  3. Create four variables of type double and assign them to the protocol with commParams:
    • Latitude: path="latitude"
    • Longitude: path="longitude"
    • Altitude: path="altitude"
    • Velocity: path="velocity"
  4. For sensible graph and gauge ranges, use presentations: latitude −90…90°, longitude −180…180°, altitude 380…460 km, velocity 27,400…27,700 km/h.
  5. Start the runtime — the values come alive right after the first API response.

Projects

Two ordinary class-library projects; they reference only the QSuite base projects (Driver, resp. Protocol + VariableEvents). HttpClient is part of .NET, no NuGet package is needed.

<!-- IssDriver.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <AssemblyName>Qenex.QSuite.Examples.IssDriver</AssemblyName>
        <RootNamespace>Qenex.QSuite.Examples.IssDriver</RootNamespace>
    </PropertyGroup>
    <ItemGroup>
      <ProjectReference Include="..\..\Drivers\Driver\Driver.csproj" />
    </ItemGroup>
</Project>
<!-- IssJsonProtocol.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <AssemblyName>Qenex.QSuite.Examples.IssJsonProtocol</AssemblyName>
        <RootNamespace>Qenex.QSuite.Examples.IssJsonProtocol</RootNamespace>
    </PropertyGroup>
    <ItemGroup>
      <ProjectReference Include="..\..\Protocols\Protocol\Protocol.csproj" />
      <ProjectReference Include="..\..\Variables\VariableEvents\VariableEvents.csproj" />
    </ItemGroup>
</Project>

Driver: IssDriver.cs

The whole driver life cycle in one place: DefaultRawSettings as the settings template, the state machine in StartAsync/StopAsync and one loop calling the API every period. Three spots worth noticing:

  • SetConfiguration enforces a minimum period of 1000 ms — this is a free public service and politeness is part of the design, not a footnote.
  • A network error does not kill the loop. A dropped connection or a slow response is just logged as a warning and the loop carries on — for a device connected over the internet this is a normal operating state, not a reason to go Faulted.
  • Send/SendAsync are empty. The API is read-only, so the driver has nothing to transmit. The full write path is shown in the temperature sensor example.
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.IssDriver;

/// <summary>
/// Example driver: reads live data from a public REST API — the current position of the
/// International Space Station. Every period it performs one HTTP GET and hands the raw JSON
/// response to its protocols; turning the JSON into variable values is the protocol's job
/// (see the IssJsonProtocol example).
/// This is about the smallest possible real-data driver: read-only, one fixed URL, one setting.
/// </summary>
public class IssDriver : DriverBase, ITransportSource<string>
{
    // Free, key-less API returning one flat JSON object with the current ISS position.
    // Be polite to the public service: do not poll faster than ~1 request per second.
    private const string Url = "https://api.wheretheiss.at/v1/satellites/25544";

    private int periodMs = 1000;

    private HttpClient? httpClient;
    private CancellationTokenSource? runCts;
    private Task? runTask;

    public IssDriver()
    {
        Specification = new SpecificationBase
        {
            Name = "IssDriver",
            Label = "ISS Position (example)",
            Description = "Example driver polling the ISS position from a public REST API.",
            CreatedOn = new DateTime(2026, 7, 27),
            Version = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(1, 0, 0, 0),
            Author = "Qenex",
            Company = "QENEX Ltd."
        };
    }

    // Shown pre-filled when the driver is added in Project Configuration.
    public override string DefaultRawSettings => "periodMs=1000";

    public override void SetConfiguration()
    {
        // "key=value;..." — a missing or invalid key silently keeps the default.
        foreach (var part in RawSettings.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
        {
            var pair = part.Split('=', 2, StringSplitOptions.TrimEntries);
            if (pair.Length == 2
                && pair[0].Equals("periodMs", StringComparison.OrdinalIgnoreCase)
                && int.TryParse(pair[1], out var parsedPeriod))
            {
                // The API asks for at most ~1 request per second, so slower is allowed, faster is not.
                periodMs = Math.Max(parsedPeriod, 1000);
            }
        }
    }

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

        // Short timeout so a slow or unreachable server cannot block the loop for long.
        httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };

        foreach (var protocol in Protocols)
        {
            await protocol.StartAsync(ct);
        }

        runCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        runTask = RunLoopAsync(runCts.Token);
        SetState(CommunicationState.Running);
    }

    public override async Task StopAsync(CancellationToken ct = default)
    {
        SetState(CommunicationState.Stopping);

        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, "ISS position: the polling loop did not stop before timeout.");
            }
        }

        foreach (var protocol in Protocols)
        {
            await protocol.StopAsync(ct);
        }

        httpClient?.Dispose();
        httpClient = null;
        runCts?.Dispose();
        runCts = null;
        runTask = null;
        SetState(CommunicationState.Stopped);
    }

    public override void Dispose()
    {
        httpClient?.Dispose();
        runCts?.Dispose();
    }

    // The API is read-only, so there is nothing to send. A writable driver would push the
    // encoded data to the device here (see the TempSensorDriver example).
    public override void Send<T>(T data)
    {
    }

    public override Task SendAsync<T>(T data, CancellationToken ct = default)
    {
        return Task.CompletedTask;
    }

    private async Task RunLoopAsync(CancellationToken ct)
    {
        try
        {
            while (!ct.IsCancellationRequested)
            {
                try
                {
                    // One GET returns one JSON object carrying several signals at once
                    // (latitude, longitude, altitude, velocity, ...).
                    var json = await httpClient!.GetStringAsync(Url, ct);

                    foreach (var protocol in Protocols)
                    {
                        if (protocol is ProtocolBase<string> textProtocol)
                        {
                            await textProtocol.AddReceivedDataToQueueAsync([json], ct);
                        }
                    }
                }
                catch (Exception e) when (e is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
                {
                    // Network hiccups are normal with a public API: log it and try again next period.
                    Logger?.Log(LogLevel.Warn, $"ISS position: request failed ({e.Message}).");
                }

                await Task.Delay(periodMs, ct);
            }
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            // Normal stop.
        }
        catch (Exception e)
        {
            Logger?.Log(LogLevel.Error, $"ISS position loop failed: {e.Message}");
            SetState(CommunicationState.Faulted, e.Message);
        }
    }
}

Protocol: IssJsonProtocol.cs

The protocol is even shorter than the driver, mostly thanks to one decision: it has no queue and no worker thread of its own. The data arrives once a second, so it is decoded straight in AddReceivedDataToQueueAsync — on the driver's polling task. StartAsync/StopAsync then only flip the state. On fast buses this would be a bad idea (a driver must never wait for processing); the temperature sensor example shows a proper queue with its own consumer.

The decoding in Decode is straightforward: parse the document and give a value to every variable whose path points to a numeric field. Encode returns nothing — a REST API you can only read from leaves nothing to encode.

using System.Reflection;
using System.Text.Json;
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.IssJsonProtocol;

/// <summary>
/// Example protocol: picks numeric fields out of the received ISS position JSON by name. Each
/// variable states which field it wants via the "path" commParam (path="latitude"), so one JSON
/// document can feed many variables at once. Nothing here is ISS-specific except the name — the
/// same code would decode any flat JSON object — but as an example it is paired with the IssDriver.
/// </summary>
public class IssJsonProtocol : ProtocolBase<string>
{
    public IssJsonProtocol()
    {
        Specification = new SpecificationBase
        {
            Name = "IssJsonProtocol",
            Label = "ISS JSON Protocol (example)",
            Description = "Example protocol decoding numeric fields of the ISS position JSON into variables.",
            CreatedOn = new DateTime(2026, 7, 27),
            Version = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(1, 0, 0, 0),
            Author = "Qenex",
            Company = "QENEX Ltd."
        };
    }

    // The transport type (string) alone cannot tell text-line drivers apart, and this
    // protocol only understands the ISS position JSON, so it narrows itself to its driver.
    public override IReadOnlyList<string> CompatibleDrivers => ["IssDriver"];

    // The protocol has no protocol-level settings; everything is per-variable commParams.
    public override void SetConfiguration()
    {
    }

    public override string CreateDefaultCommParam(IVariableBase variable, IEnumerable<IVarEvent> variableEvents)
    {
        return "path=\"latitude\"";
    }

    public override IProtocolVariable? CreateProtocolVariable(IVariableBase variable, string commParams, bool isCommunicated)
    {
        try
        {
            return new ProtocolVariable
            {
                Variable = variable,
                IsCommunicated = isCommunicated,
                ProtocolVariableSpecification = IssJsonVariableSpecification.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, $"path=\"{id}\"", true);
    }

    public override IProtocolVariable? CreateProtocolVariable(IVariableBase variable, IEnumerable<IVarEvent> variableEvents,
        string commParams, bool isCommunicated)
    {
        // The data arrive whenever the driver polls them, so variable events are not used.
        return CreateProtocolVariable(variable, commParams, isCommunicated);
    }

    // No worker loop is needed: received documents are decoded directly in
    // AddReceivedDataToQueueAsync, so start/stop only maintain the state machine.
    // (For a queued, cancelable consumer loop see the TempSensorProtocol example.)
    public override Task StartAsync(CancellationToken ct = default)
    {
        SetState(IsEnabled ? CommunicationState.Running : CommunicationState.Disabled);
        return Task.CompletedTask;
    }

    public override Task StopAsync(CancellationToken ct = default)
    {
        SetState(CommunicationState.Stopped);
        return Task.CompletedTask;
    }

    public override void Dispose()
    {
    }

    // At ~1 document per second there is no need for an internal queue — the data are
    // decoded right away on the driver's polling task.
    public override async Task AddReceivedDataToQueueAsync(IEnumerable<string> data, CancellationToken ct = default)
    {
        if (State != CommunicationState.Running)
        {
            return;
        }

        await ProcessReceivedDataAsync(data, ct);
    }

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

    protected override IEnumerable<IProtocolVariable> Decode(IEnumerable<string> data)
    {
        var updated = new List<IProtocolVariable>();

        foreach (var json in data)
        {
            JsonDocument document;
            try
            {
                document = JsonDocument.Parse(json);
            }
            catch (JsonException e)
            {
                Logger?.Log(LogLevel.Warn, $"ISS JSON protocol: cannot parse received data ({e.Message}).");
                continue;
            }

            using (document)
            {
                // Every variable whose "path" names a numeric field of this document gets its value.
                foreach (var protocolVariable in Variables)
                {
                    if (!protocolVariable.IsCommunicated
                        || protocolVariable.ProtocolVariableSpecification is not IssJsonVariableSpecification spec
                        || protocolVariable.Variable is not ScalarVariable scalarVariable
                        || !document.RootElement.TryGetProperty(spec.Path, out var field)
                        || field.ValueKind != JsonValueKind.Number)
                    {
                        continue;
                    }

                    if (!TrySetValue(scalarVariable, field.GetDouble()))
                    {
                        Logger?.Log(LogLevel.Warn, $"ISS JSON protocol: variable '{scalarVariable.Name}' has an unsupported value type.");
                        continue;
                    }

                    scalarVariable.Timestamp = DateTime.UtcNow;
                    updated.Add(protocolVariable);
                }
            }
        }

        return updated;
    }

    // The protocol is read-only (a REST API you can only GET), so nothing is ever encoded.
    // For the write direction see the TempSensorProtocol example.
    protected override IEnumerable<string> Encode(IEnumerable<IProtocolVariable> protocolVariables)
    {
        yield break;
    }

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

Variable specification: IssJsonVariableSpecification.cs

The addressing is a single item: the JSON field name. The Path property matches the commParam key 1:1, so the inherited ToCommParam() serialization works without an override — the same trick as in the temperature sensor example.

using Qenex.QSuite.Protocols.Protocol;

namespace Qenex.QSuite.Examples.IssJsonProtocol;

/// <summary>
/// Addressing of one variable: the name of the JSON field whose value the variable receives.
/// The property matches the commParam key 1:1 ("path"), so the inherited reflection-based
/// ToCommParam() round-trips the specification without an override.
/// </summary>
public class IssJsonVariableSpecification : ProtVariableSpecification
{
    public IssJsonVariableSpecification()
    {
        Name = "IssJsonVariableSpecification";
    }

    /// <summary>Name of the field in the received JSON object, e.g. "latitude". Case-sensitive.</summary>
    public string Path { get; set; } = string.Empty;

    public static IssJsonVariableSpecification 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("path", out var path) || string.IsNullOrWhiteSpace(path))
        {
            throw new ArgumentException("Missing mandatory commParam 'path'.");
        }

        return new IssJsonVariableSpecification { Path = path };
    }
}

How the members relate

StepWhoMembers
Adding the driver/protocol in the UIpluginDefaultRawSettings, CreateDefaultCommParam
Assigning a variableprotocolCreateProtocolVariable → the spec's Create
Runtime startdriverStartAsync → start of protocols → the polling loop
Reading datadriver → protocolHTTP GET → AddReceivedDataToQueueAsyncDecodeNotifyValueChangedAsync
StopdriverStopAsync (cancel the loop, stop protocols, dispose HttpClient)

What the example deliberately leaves out

To stay as short as possible, three things are intentionally missing — and all three are shown in the temperature sensor example:

  • the write path (an operator writes a value → the protocol encodes it → the driver transmits it),
  • a received-data queue with its own consumer — a necessity wherever data arrives faster than it is processed,
  • a communication direction in commParams (direction="read/write") — everything here is read-only, so the parameter is not needed.

One side note: apart from the name, there is nothing ISS-specific in the protocol. The same code would decode any flat JSON object — just change the URL in the driver and the path values.

Next

Example temp sensor — the full pattern including writes and the queue. The individual skeletons: Custom driver and Custom protocol.