Get started

The first two Komods

In this tutorial you build two Komods that pass data to each other in the KOFA graph:

 ┌───────────────┐  temperature (Scalar, °C)  ┌───────────────┐  heater (BoolValue)
 │  Thermometer  │ ─────────────────────────▶ │  Thermostat   │ ───────────────────▶ …
 │  (simulated)  │                            │ + dashboard   │
 └───────────────┘                            │   tile        │
                                              └───────────────┘
  • The thermometer simulates a sinusoidal, noisy temperature and publishes it on its temperature output.
  • The thermostat decides, from the value arriving on its temperature input, whether the heater should be on or off (with hysteresis), and reports it on its heater output. If the thermometer goes silent, it switches to a safe state (heater off).
  • The thermostat also provides a tile for the dashboard: it shows the temperature and the heater, and the target temperature can be changed on it.

Prerequisite: the tools are installed.

Note

The template code is shared with the Hungarian documentation, so the display names and status texts in the samples are Hungarian. Change them freely.

1. Create the project

dotnet new kofa-komod-quickstart -n Acme.Demo --authorName "Acme Ltd."
cd Acme.Demo

After -n comes your own name (Company.Project). In Visual Studio the "KOFA Komod példa" template in the New project window does the same. The created folder:

Acme.Demo/
  Acme.Demo.slnx
  nuget.config                      the KOFA NuGet source
  Acme.Demo.Thermometer/            the thermometer (server part)
    ThermometerKomod.cs
    Package.props                   the package id, version, name
  Acme.Demo.Thermostat/             the thermostat (server part)
    ThermostatKomod.cs
    Package.props
  Acme.Demo.Thermostat.Ui/          the thermostat's tile (browser part)
    ThermostatWidget.razor

A Komod package can consist of two projects: X, which runs on the server, and X.Ui, which runs in the browser; they share one Package.props. The thermometer has no UI, so it has only a server project.

2. The thermometer

Settings

The settings are a plain record. KOFA builds a form from it, validates the attributes ([Range] etc.) and stores it as JSON.

public sealed record ThermometerSettings
{
    [Range(-50, 150)]
    public double BaseCelsius { get; init; } = 21;

    [Range(0, 50)]
    public double AmplitudeCelsius { get; init; } = 4;

    [Range(1, 3600)]
    public double PeriodSeconds { get; init; } = 60;

    [Range(0, 5)]
    public double NoiseCelsius { get; init; } = 0.2;

    [Range(0.1, 20)]
    public double RateHz { get; init; } = 2;
}

The Komod

[Komod("acme.demo.thermometer",
    DisplayName = "Hőmérő (szimulált)",
    Description = "Szimulált hőmérő: szinuszos hőmérsékletet és zajt ad a kimenetén.",
    Category = KomodCategory.Source)]
public sealed class ThermometerKomod : KomodBase<ThermometerSettings>
{
    [Output("temperature", DisplayName = "Hőmérséklet", Description = "°C")]
    public required IOutput<Scalar> Temperature { get; init; }

    protected override async Task ExecuteAsync(CancellationToken cancellationToken)
    {
        var started = Context.TimeProvider.GetTimestamp();
        // Settings are re-read in every round: a settings change applies without restarting the instance.
        while (!cancellationToken.IsCancellationRequested)
        {
            var settings = Settings;
            var elapsed = Context.TimeProvider.GetElapsedTime(started).TotalSeconds;
            var value = settings.BaseCelsius
                + settings.AmplitudeCelsius * Math.Sin(2 * Math.PI * elapsed / settings.PeriodSeconds)
                + (Random.Shared.NextDouble() * 2 - 1) * settings.NoiseCelsius;

            Temperature.Publish(new Scalar(Math.Round(value, 2), "°C"));
            Context.ReportStatus(KomodHealth.Ok, $"{value:0.0} °C");

            try
            {
                await Task.Delay(TimeSpan.FromSeconds(1 / settings.RateHz), Context.TimeProvider, cancellationToken);
            }
            catch (OperationCanceledException)
            {
                break;
            }
        }
    }

    // True: the new settings are already in use (see above), no restart needed.
    protected override Task<bool> OnSettingsChangedAsync(ThermometerSettings oldSettings, ThermometerSettings newSettings) =>
        Task.FromResult(true);
}

What happens here:

  • The [Komod("…")] attribute identifies the class. The id is lowercase, separated by dots or hyphens, and must not change between versions: the graph and the package refer to it. Category is the classification in the store and the palette.
  • The [Output("temperature")] property is an output port. KOFA fills it in at load time (required … init); you only Publish into it. Scalar is a general measurement with a unit; the other data types are used the same way.
  • ExecuteAsync is the main loop: it runs until the instance is stopped (cancellationToken). It may also return early (passive Komod).
  • Settings is always the current setting. The loop re-reads it in every round, so OnSettingsChangedAsync returns true: the change takes effect without a restart. On false KOFA restarts the instance.
  • Context.ReportStatus is the status line shown on the graph node and the dashboard.

3. The thermostat

Settings

public sealed record ThermostatSettings
{
    [Range(5, 35)]
    public double TargetCelsius { get; init; } = 21;

    [Range(0.1, 5)]
    public double HysteresisCelsius { get; init; } = 0.5;
}

The Komod

[Komod("acme.demo.thermostat",
    DisplayName = "Termosztát",
    Description = "Hiszterézises termosztát: a hőmérséklet alapján kapcsolja a fűtést.",
    Category = KomodCategory.Controller)]
public sealed class ThermostatKomod : KomodBase<ThermostatSettings>
{
    // The input must be linked in the graph. If no message arrives for 5 seconds, OnInputTimeout runs (failsafe).
    [Input("temperature", DisplayName = "Hőmérséklet", Deadline = "00:00:05")]
    public required IInput<Scalar> Temperature { get; init; }

    [Output("heater", DisplayName = "Fűtés")]
    public required IOutput<BoolValue> Heater { get; init; }

    private bool _heating;

    protected override async Task ExecuteAsync(CancellationToken cancellationToken)
    {
        Context.ReportStatus(KomodHealth.Warning, "Nincs adat");
        SetHeater(false);

        await foreach (var message in Temperature.ReadAllAsync(cancellationToken))
        {
            var settings = Settings;
            var celsius = message.Value.Value;

            if (celsius < settings.TargetCelsius - settings.HysteresisCelsius)
                SetHeater(true);
            else if (celsius > settings.TargetCelsius + settings.HysteresisCelsius)
                SetHeater(false);

            Context.ReportStatus(KomodHealth.Ok, $"{celsius:0.0} °C → cél {settings.TargetCelsius:0.#} °C, fűtés {(_heating ? "BE" : "KI")}");
        }
    }

    // The deadline elapsed: with no measurement the safe state is "off".
    protected override void OnInputTimeout(IInputPort port)
    {
        SetHeater(false);
        Context.ReportStatus(KomodHealth.Warning, "Nincs hőmérséklet adat, a fűtés kikapcsolt");
    }

    protected override Task<bool> OnSettingsChangedAsync(ThermostatSettings oldSettings, ThermostatSettings newSettings) =>
        Task.FromResult(true);

    private void SetHeater(bool on)
    {
        _heating = on;
        Heater.Publish(new BoolValue(on));
    }
}

What happens here:

  • [Input("temperature", Deadline = "00:00:05")] is an input port. The ReadAllAsync method of IInput<T> gives the arriving messages as an asynchronous stream. A message (KofaMessage<T>) carries a timestamp and the source besides the Value.
  • The Deadline is the basis of the failsafe: if no message arrives for 5 seconds, KOFA calls OnInputTimeout. Here we switch the heater off. Every Komod that operates something needs a deadline; see Lifecycle and safety.
  • The hysteresis prevents the heater from switching on and off quickly around the target value.

4. The tile

The browser part is an ordinary Blazor component that KOFA loads at run time. The [KomodView] attribute binds it to the Komod (id) and to a place (Widget = dashboard tile).

<div style="display:flex;flex-direction:column;gap:6px;font-variant-numeric:tabular-nums">
    <div style="font-size:1.8rem;font-weight:600">
        @(_temperature is { } t ? t.ToString("0.0", CultureInfo.InvariantCulture) + " °C" : "–")
    </div>
    <div style="display:flex;align-items:center;gap:8px">
        <button type="button" @onclick="() => AdjustTargetAsync(-0.5)">−</button>
        <span>cél: @(_target?.ToString("0.0", CultureInfo.InvariantCulture) ?? "–") °C</span>
        <button type="button" @onclick="() => AdjustTargetAsync(+0.5)">+</button>
    </div>
    <div style="font-size:.85rem;opacity:.8">Fűtés: @(_heating ? "BE" : "KI")</div>
    @if (_error is not null)
    {
        <div style="font-size:.8rem;color:#c42b1c">@_error</div>
    }
</div>

The live data comes from the server:

protected override async Task OnInitializedAsync()
{
    _target = ReadTarget(await Kofa.GetSettingsAsync(InstanceId, _cts.Token));

    // Both an input and an output port can be subscribed; the server thins the stream to maxRateHz.
    _loops.Add(Task.Run(async () =>
    {
        await foreach (var message in Kofa.SubscribeAsync<Scalar>(Port("temperature"), maxRateHz: 5, _cts.Token))
        {
            _temperature = message.Value.Value;
            await InvokeAsync(StateHasChanged);
        }
    }));
    _loops.Add(Task.Run(async () =>
    {
        await foreach (var message in Kofa.SubscribeAsync<BoolValue>(Port("heater"), maxRateHz: 5, _cts.Token))
        {
            _heating = message.Value.Value;
            await InvokeAsync(StateHasChanged);
        }
    }));
}

Changing the target temperature means saving the instance's settings. The server validates the value, applies it live (this is why the thermostat needed OnSettingsChangedAsync → true), and writes it into the active graph as well:

private async Task AdjustTargetAsync(double delta)
{
    try
    {
        // The server validates the settings, applies them live and writes them into the active graph too.
        var settings = JsonNode.Parse((await Kofa.GetSettingsAsync(InstanceId)).GetRawText())!.AsObject();
        _target = Math.Round((ReadTarget(JsonSerializer.SerializeToElement(settings)) ?? 21) + delta, 1);
        settings["targetCelsius"] = _target;
        await Kofa.SaveSettingsAsync(InstanceId, JsonSerializer.SerializeToElement(settings));
        _error = null;
    }
    catch (KomodClientException ex)
    {
        _error = ex.Message;
    }
}

Details, other view kinds and styling: User interface (UI).

5. Build and package

dotnet build Acme.Demo.slnx

The build puts the packages, unpacked, into the artifacts/komods/<id>/<version>/ folder. You create an installable .komod file like this (build the UI project first, because the server package refers to it):

dotnet build Acme.Demo.Thermostat.Ui
dotnet build Acme.Demo.Thermostat -p:KomodPack=true
dotnet build Acme.Demo.Thermometer -p:KomodPack=true

The files go to the artifacts/packages/ folder (acme.demo.thermometer-1.0.0.komod, acme.demo.thermostat-1.0.0.komod). Check with the kofa-pack tool:

kofa-pack inspect artifacts/packages/acme.demo.thermostat-1.0.0.komod

It lists the Komods and their ports (in temperature kofa.scalar/1, out heater kofa.bool/1), so you see what KOFA will see.

6. Install and connect

  1. In KOFA open the Komodok page (with an admin account) and drop the two .komod files on the upload field. The preview shows the manifest and the ports; install both.
  2. On the Graph page drag the Hőmérő (szimulált) and the Termosztát Komod from the palette onto the canvas.
  3. Link the thermometer's temperature output to the thermostat's temperature input.
  4. Press Apply. The nodes show the status line, the link shows the live value.
  5. Add the thermostat's tile to the dashboard. Set the target with the −/+ buttons and watch the heater switch.

Tip

When you change the code: raise KomodPackageVersion in Package.props, repack and upload. During development it is faster if the KOFA server watches the artifacts/komods folder, because then the Reload button is enough; see Build and pack.

What next

  • More Komods from the elements above: Concepts.
  • Real hardware (an I2C sensor, a camera) instead of the simulated thermometer: Hardware.
  • A button or a stick in the browser that controls the Komod: UI input.
  • Publishing in the Komod store: Install and publish.