Első lépések

Az első két Komod

Ebben az oktatóanyagban két Komodot készítesz, amelyek a KOFA gráfban egymásnak adják az adatot:

 ┌───────────────┐  temperature (Scalar, °C)  ┌───────────────┐  heater (BoolValue)
 │    Hőmérő     │ ─────────────────────────▶ │  Termosztát   │ ───────────────────▶ …
 │  (szimulált)  │                            │ + dashboard   │
 └───────────────┘                            │   csempe      │
                                              └───────────────┘
  • A hőmérő szinuszosan változó, zajos hőmérsékletet szimulál, és a temperature kimenetén adja.
  • A termosztát a temperature bemenetére érkező értékből dönti el, hogy a fűtés be- vagy kikapcsoljon (hiszterézissel), és a heater kimenetén jelzi. Ha a hőmérő elnémul, biztonsági állapotba (fűtés ki) kapcsol.
  • A termosztát csempét is ad a dashboardra: mutatja a hőmérsékletet és a fűtést, és a cél hőmérséklet állítható rajta.

Előfeltétel: az eszközök telepítve vannak.

1. A projekt létrehozása

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

A -n után a saját neved áll (Cég.Projekt alakban). Visual Studióban az Új projekt ablakban a „KOFA Komod példa” sablon ugyanezt csinálja. A létrejött mappa:

Acme.Demo/
  Acme.Demo.slnx
  nuget.config                      a KOFA NuGet forrás
  Acme.Demo.Thermometer/            a hőmérő (szerver rész)
    ThermometerKomod.cs
    Package.props                   a csomag azonosítója, verziója, neve
  Acme.Demo.Thermostat/             a termosztát (szerver rész)
    ThermostatKomod.cs
    Package.props
  Acme.Demo.Thermostat.Ui/          a termosztát csempéje (böngésző rész)
    ThermostatWidget.razor

Egy Komod csomag két projektből állhat: a szerveren futó X és a böngészőben futó X.Ui projektből, amelyek közös Package.props-ot használnak. A hőmérőnek nincs felülete, ezért csak szerver projektje van.

2. A hőmérő

Beállítások

A beállítás egy egyszerű rekord. A KOFA ebből készít űrlapot, ellenőrzi az attribútumokat ([Range] stb.), és JSON-ként tárolja.

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

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

Mi történik itt:

  • A [Komod("…")] attribútum azonosítja az osztályt. Az azonosító kisbetűs, ponttal vagy kötőjellel tagolt, és verziók között nem változhat: ezzel hivatkozik rá a gráf és a csomag. A Category a Komod tárbeli és palettabeli besorolása.
  • A [Output("temperature")] property egy kimeneti port. A KOFA tölti ki betöltéskor (required … init), te csak Publish-olsz bele. A Scalar egy általános mérőszám mértékegységgel; a többi adattípus is hasonlóan használható.
  • Az ExecuteAsync a fő ciklus: addig fut, amíg a példány le nem áll (cancellationToken). Visszatérhet korán is (passzív Komod).
  • A Settings mindig az aktuális beállítás. A ciklus minden körben újra olvassa, ezért OnSettingsChangedAsync true-t ad: a változás újraindítás nélkül érvényesül. false esetén a KOFA újraindítja a példányt.
  • Context.ReportStatus a gráf csomópontján és a dashboardon megjelenő állapotsor.

3. A termosztát

Beállítások

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

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

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

Mi történik itt:

  • A [Input("temperature", Deadline = "00:00:05")] egy bemeneti port. Az IInput<T> ReadAllAsync metódusa aszinkron folyamként adja a beérkező üzeneteket. Az üzenet (KofaMessage<T>) a Value mellett időbélyeget és forrást is hordoz.
  • A Deadline a failsafe alapja: ha 5 másodpercig nem érkezik üzenet, a KOFA meghívja az OnInputTimeout-ot. Itt a fűtést kikapcsoljuk. Minden olyan Komodnak, amely valamit működtet, kell határidő; részletek: Életciklus és biztonság.
  • A hiszterézis megakadályozza, hogy a fűtés a célérték körül gyorsan ki-be kapcsoljon.

4. A csempe

A böngésző rész egy közönséges Blazor komponens, amelyet a KOFA futás közben tölt be. A [KomodView] attribútum köti a Komodhoz (azonosító) és a helyhez (Widget = dashboard csempe).

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

Az élő adat a szerverről jön:

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

A cél hőmérséklet állítása a példány beállításának mentése. A szerver ellenőrzi az értéket, élőben alkalmazza (ezért kellett a termosztátnál OnSettingsChangedAsync → true), és beírja az aktív gráfba is:

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

Részletek, további nézettípusok és a stílusozás: Felület (UI).

5. Build és csomagolás

dotnet build Acme.Demo.slnx

A build a csomagokat kicsomagolva az artifacts/komods/<id>/<verzió>/ mappába teszi. Telepíthető .komod fájlt így készítesz (előbb a UI projektet buildeld, mert a szerver csomag erre hivatkozik):

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

A fájlok az artifacts/packages/ mappába kerülnek (acme.demo.thermometer-1.0.0.komod, acme.demo.thermostat-1.0.0.komod). Ellenőrzés a kofa-pack eszközzel:

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

Kiírja a Komodokat és a portjaikat (be temperature kofa.scalar/1, ki heater kofa.bool/1), így látod, hogy a KOFA mit fog látni.

6. Telepítés és összekötés

  1. A KOFA-ban nyisd meg a Komodok oldalt (admin fiókkal), és húzd rá a két .komod fájlt a feltöltési mezőre. Az előnézet a manifestet és a portokat mutatja; telepítsd mindkettőt.
  2. A Gráf oldalon húzd a vászonra a Hőmérő (szimulált) és a Termosztát Komodot a palettáról.
  3. Kösd a hőmérő Hőmérséklet kimenetét a termosztát Hőmérséklet bemenetére.
  4. Nyomd meg az Alkalmaz gombot. A csomópontokon megjelenik az állapotsor, a kötésen az élő érték.
  5. A termosztát csempéjét add a dashboardhoz. Állítsd a célt a −/+ gombokkal, és figyeld a fűtés kapcsolását.

Tipp

Ha módosítasz a kódon: emeld a Package.props KomodPackageVersion értékét, csomagold újra és töltsd fel. Fejlesztés közben gyorsabb, ha a KOFA szerver az artifacts/komods mappát figyeli, mert ott elég az Újratöltés gomb; lásd: Build és csomagolás.

Hogyan tovább