View on GitHub

Smart Application Framework (SAF)

Open-source, cross-platform framework for distributed applications across cloud and edge.

Toolbox Services

The SAF Toolbox (SAF.Toolbox) is a collection of ready-made helpers that plug-in authors can register in their plugin’s DI container to simplify common tasks.


Heartbeat

A testable, configurable timer that fires a Beat event at a fixed interval. Prefer this over System.Threading.Timer inside plug-ins because it is easier to control in tests.

Registration

// Single heartbeat
pluginServices.AddHeartbeat(heartbeatMillis: 1000);

// Pool (lazily creates heartbeats at different rates)
pluginServices.AddHeartbeatPool();

Usage

public class PollingService(IHeartbeat heartbeat) : IServicePlugin, IDisposable
{
    public Task StartAsync(CancellationToken token)
    {
        heartbeat.Beat += OnBeat;
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken token)
    {
        heartbeat.Beat -= OnBeat;
        return Task.CompletedTask;
    }

    private void OnBeat(object? sender, HeartbeatEventArgs e)
    {
        // called every BeatCycleTimeMillis
        Console.WriteLine($"Beat #{e.BeatCount}");
    }

    public void Dispose() { /* unsubscribe if needed */ }
}

IHeartbeat Members

Member Description
Beat Event fired each cycle
BeatCycleTimeMillis Configured cycle time
CurrentBeat Monotonically increasing beat counter
WallClockTimeAgo(n) Convert n beats to a TimeSpan

Heartbeat Pool

When different services need different rates, use IHeartbeatPool:

public class MultiRateService(IHeartbeatPool pool)
{
    public void Start()
    {
        var fast = pool.GetOrCreateHeartbeat(500);   // 500 ms
        var slow = pool.GetOrCreateHeartbeat(5000);  // 5 000 ms

        fast.Beat += (_, _) => PollSensor();
        slow.Beat += (_, _) => ReportStatus();
    }
}

Request Client

Implements the request/reply pattern on top of IMessagingInfrastructure. The caller publishes a request message and awaits a single reply on a dynamically generated reply topic.

Registration

pluginServices.AddRequestClient();

Usage

Define request and response types (typically in a shared contracts assembly):

// Contracts
public class GetOrderRequest : MessageRequestBase
{
    public string OrderId { get; set; } = default!;
}

public class GetOrderResponse
{
    public string OrderId { get; set; } = default!;
    public string Status  { get; set; } = default!;
}

Send a request and await the first matching reply:

public class OrderClient(IRequestClient client)
{
    public async Task<GetOrderResponse?> GetOrderAsync(string orderId)
    {
        return await client.SendRequestAwaitFirstAnswer<GetOrderRequest, GetOrderResponse>(
            topic: "orders/get",
            request: new GetOrderRequest { OrderId = orderId },
            millisecondsTimeoutTarget: 5000);
    }
}

Respond from another plugin:

public class OrderResponder(IMessagingInfrastructure messaging) : IMessageHandler
{
    public bool CanHandle(Message message) =>
        message.Topic == "orders/get";

    public void Handle(Message message)
    {
        var request  = JsonSerializer.Deserialize<GetOrderRequest>(message.Payload!);
        var response = new GetOrderResponse
        {
            OrderId = request!.OrderId,
            Status  = "Confirmed"
        };

        // Reply to the reply channel carried in the request
        messaging.Publish(new Message
        {
            Topic   = request.ReplyTo!,
            Payload = JsonSerializer.Serialize(response)
        });
    }
}

File Handling

Provides an IDirectoryInfo rooted at IServiceHostInfo.FileSystemUserBasePath — a safe, host-managed scratch area for file I/O.

SAF.Toolbox uses Testably.Abstractions as the concrete file system implementation (IFileSystemRealFileSystem).

Registration

pluginServices.AddFileHandling();

Usage

public class ConfigLoader(IDirectoryInfo baseDir)
{
    public string LoadConfig(string name)
    {
        var file = baseDir.FileSystem.FileInfo.New(
            Path.Combine(baseDir.FullName, name));
        return file.Exists ? file.FileSystem.File.ReadAllText(file.FullName) : string.Empty;
    }
}

File Transfer

Transfers files between SAF hosts by chunking them over the messaging infrastructure. Suitable for sending firmware updates, log bundles, or configuration packages between distributed nodes.

Components

Type Description
IFileSender Chunks a file and transfers it to a topic (SendAsync(topic, fullFilePath, timeoutMs))
IStatefulFileReceiverFactory Creates a stateful receiver bound to a destination folder (CreateForFolder(folderPath))
IStatefulFileReceiver Reassembles incoming chunks; raises TargetFilePathResolved, BeforeFileReceived, FileReceived
IFileReceiver Wires a stateful receiver to a topic (Subscribe(topic, receiver) / Unsubscribe)

Registration

pluginServices.AddFileHandling();
pluginServices.AddFileSender();
pluginServices.AddFileReceiver();  // also registers IStatefulFileReceiverFactory

Sending a File

SendAsync takes the destination topic, the full path of the file, and a timeout in milliseconds. It returns a FileTransferStatus.

public class FirmwareUpdater(IFileSender sender)
{
    public async Task<FileTransferStatus> SendFirmwareAsync(string filePath, string targetTopic)
    {
        return await sender.SendAsync(targetTopic, filePath, timeoutMs: 60_000)
            .ConfigureAwait(false);
    }
}

Receiving a File (Stateful)

Create a receiver for a destination folder, subscribe it to a topic via IFileReceiver, and handle the FileReceived event.

public class FirmwareReceiver(
    IFileReceiver fileReceiver,
    IStatefulFileReceiverFactory receiverFactory) : IServicePlugin
{
    private const string Topic = "firmware/update";
    private IStatefulFileReceiver? _receiver;

    public Task StartAsync(CancellationToken token)
    {
        _receiver = receiverFactory.CreateForFolder("./firmware");

        _receiver.FileReceived += (_, e) =>
            Console.WriteLine($"Firmware received: {e.LocalFileFullName}");

        fileReceiver.Subscribe(Topic, _receiver);
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken token)
    {
        fileReceiver.Unsubscribe(Topic);
        _receiver?.Dispose();
        return Task.CompletedTask;
    }
}

Serialization Helpers

SAF.Toolbox includes IJsonObjectConverter implementations used by IRequestClient for custom JSON serialization. Inject an array of converters into the SendRequestAwaitFirstAnswer overload when your payload types require non-default serialization.


Full Registration Reference

// In IPluginManifest.ConfigureServices:

pluginServices.AddHeartbeat(1000);         // single heartbeat, 1 second
pluginServices.AddHeartbeatPool();          // pool for multiple rates

pluginServices.AddRequestClient();          // request/reply helper

pluginServices.AddFileHandling();           // IDirectoryInfo

pluginServices.AddFileSender();             // IFileSender
pluginServices.AddFileReceiver();           // IFileReceiver + IStatefulFileReceiverFactory