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
temperatureoutput. - The thermostat decides, from the value arriving on its
temperatureinput, whether the heater should be on or off (with hysteresis), and reports it on itsheateroutput. 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.Categoryis 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 onlyPublishinto it.Scalaris a general measurement with a unit; the other data types are used the same way. ExecuteAsyncis the main loop: it runs until the instance is stopped (cancellationToken). It may also return early (passive Komod).Settingsis always the current setting. The loop re-reads it in every round, soOnSettingsChangedAsyncreturnstrue: the change takes effect without a restart. OnfalseKOFA restarts the instance.Context.ReportStatusis 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. TheReadAllAsyncmethod ofIInput<T>gives the arriving messages as an asynchronous stream. A message (KofaMessage<T>) carries a timestamp and the source besides theValue.- The
Deadlineis the basis of the failsafe: if no message arrives for 5 seconds, KOFA callsOnInputTimeout. 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
- In KOFA open the Komodok page (with an admin account) and drop the two
.komodfiles on the upload field. The preview shows the manifest and the ports; install both. - On the Graph page drag the Hőmérő (szimulált) and the Termosztát Komod from the palette onto the canvas.
- Link the thermometer's temperature output to the thermostat's temperature input.
- Press Apply. The nodes show the status line, the link shows the live value.
- 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.