Hardware & External Services
The Dale SDK provides multiple ways to connect logic blocks to the outside world — from digital I/O and Modbus registers to HTTP APIs. Service provider contracts connect to hardware through the runtime, while utility packages like Modbus TCP and HTTP are injected via dependency injection for direct communication.
Built-in I/O Interfaces
Dale provides built-in interfaces for common I/O types:
| Interface | Description | Value Type |
|---|---|---|
IDigitalInput | Boolean input (button, switch, contact) | bool |
IDigitalOutput | Boolean output (LED, relay, valve) | bool |
IAnalogInput | Numeric input (temperature sensor, light sensor) | double |
IAnalogOutput | Numeric output (dimmer, valve position, fan speed) | double |
Declaring I/O Contracts
Use [ServiceProviderContract] on a property to declare the I/O binding. The defaultName provides a human-readable label shown in the Dashboard during wiring.
[LogicBlockInfo("Toggle Light")]
public class ToggleLightBlock : LogicBlockBase
{
public ToggleLightBlock(ILogger logger) : base(logger) { }
[ServiceProviderContract(defaultName: "Button")]
public IDigitalInput Button { get; set; } = null!;
[ServiceProviderContract(defaultName: "LED")]
public IDigitalOutput Led { get; set; } = null!;
[ServiceProperty("LED Enabled")]
[Importance(Importance.Primary)]
public bool LedEnabled { get; private set; }
protected override void Ready()
{
Button.InputChanged += (sender, value) =>
{
LedEnabled = value;
Led.Set(value);
};
}
}Handling Input Events
Subscribe to InputChanged in the Ready() lifecycle method. The event fires whenever the physical (or simulated) input changes state.
protected override void Ready()
{
// Digital: value is a bool
Button.InputChanged += (sender, value) =>
{
Led.Set(value);
};
// Analog: value is a double
TemperatureSensor.InputChanged += (sender, value) =>
{
CurrentTemperature = value;
if (value > Threshold)
HeaterOutput.Set(0.0);
};
}Writing to Outputs
Call Set() on an output property at any time:
Led.Set(true);
HeaterOutput.Set(0.75); // 75% powerModbus RTU Example
The Dale.Sdk.Modbus.Rtu package provides the IModbusRtu interface — a service provider contract that works just like IDigitalInput or IAnalogOutput. You declare it with [ServiceProviderContract], the runtime injects an implementation, and you use it to read and write Modbus registers directly.
[LogicBlockInfo("EM122 Electricity Meter", "flashlight-line")]
public class Em122ElectricityMeter : LogicBlockBase
{
public Em122ElectricityMeter(ILogger logger) : base(logger) { }
[ServiceProviderContract("Modbus", "EM122 Modbus RTU")]
public IModbusRtu Modbus { get; set; } = null!;
[ServiceProperty("Unit ID")]
[Category(PropertyCategory.Configuration)]
public int UnitId { get; set; } = 1;
[ServiceProperty("Voltage L1", "V")]
[ServiceMeasuringPoint("Voltage L1", "V")]
public double VoltageL1 { get; private set; }
[ServiceProperty("Voltage L2", "V")]
[ServiceMeasuringPoint("Voltage L2", "V")]
public double VoltageL2 { get; private set; }
[ServiceProperty("Voltage L3", "V")]
[ServiceMeasuringPoint("Voltage L3", "V")]
public double VoltageL3 { get; private set; }
[ServiceProperty("Read Count")]
public int ReadCount { get; private set; }
[ServiceProperty("Error Count")]
public int ErrorCount { get; private set; }
protected override void Ready()
{
Modbus.IsEnabled = true;
}
// Poll every 2 seconds
[Timer(2)]
public void Poll()
{
// Batch read: 3 contiguous float registers starting at address 0
Modbus.ReadInputRegistersAsFloat(
UnitId,
startingAddress: 0,
quantity: 3,
successCallback: values =>
{
VoltageL1 = values[0];
VoltageL2 = values[1];
VoltageL3 = values[2];
ReadCount++;
},
errorCallback: OnError);
}
private void OnError(Exception ex)
{
ErrorCount++;
Logger.LogWarning(ex, "Modbus error at unit {UnitId}", UnitId);
}
}The IModbusRtu interface provides typed read/write methods for all standard Modbus operations:
| Method | Modbus Function |
|---|---|
ReadDiscreteInputs | FC 2 — read discrete inputs |
ReadCoils / WriteSingleCoil / WriteMultipleCoils | FC 1, 5, 15 — coils |
ReadInputRegistersAs{Float,Int,Short,...} | FC 4 — read input registers |
ReadHoldingRegistersAs{Float,Int,Short,...} | FC 3 — read holding registers |
WriteSingleHoldingRegister / WriteMultipleHoldingRegistersAs{Float,...} | FC 6, 16 — write holding registers |
All operations are callback-based and support configurable byte order, word order, and operation timeout:
// Write a holding register with explicit byte order
Modbus.WriteMultipleHoldingRegistersAsFloat(
UnitId,
registerAddress: 2,
values: new[] { 15.0f },
successCallback: () => Logger.LogInformation("Register written"),
errorCallback: OnError,
byteOrder: ByteOrder.MsbToLsb);Modbus TCP
The Dale.Sdk.Modbus.Tcp package provides ILogicBlockModbusTcpClient — a queue-based Modbus TCP client injected via dependency injection (not a service provider contract like RTU). This is useful when your logic block connects directly to a Modbus TCP device over the network.
[LogicBlockInfo("Energy Meter TCP")]
public class EnergyMeterTcp : LogicBlockBase
{
private readonly ILogicBlockModbusTcpClient _modbus;
public EnergyMeterTcp(ILogger logger, ILogicBlockModbusTcpClient modbus) : base(logger)
{
_modbus = modbus;
}
[ServiceProperty("IP Address")]
[Category(PropertyCategory.Configuration)]
public string IpAddress { get; set; } = "192.168.1.100";
[ServiceProperty("Power", "kW")]
[ServiceMeasuringPoint("Power", "kW")]
public double Power { get; private set; }
protected override void Ready()
{
_modbus.IpAddress = IpAddress;
_modbus.IsEnabled = true;
}
[Timer(2)]
public void Poll()
{
_modbus.ReadInputRegistersAsFloat(
unitIdentifier: 1,
startingAddress: 0,
quantity: 1,
successCallback: values => { Power = values[0]; },
errorCallback: ex => Logger.LogWarning(ex, "Modbus TCP error"));
}
}The TCP client provides the same typed read/write methods as IModbusRtu (same Modbus function codes), plus connection management:
| Feature | Modbus RTU | Modbus TCP |
|---|---|---|
| Transport | Serial via service provider | TCP/IP direct connection |
| Declaration | [ServiceProviderContract] | Constructor injection (DI) |
| Connection | Managed by runtime | IpAddress, Port, ConnectionTimeout |
| Queue management | Actor-based | Configurable QueueCapacity and QueueOverflowPolicy |
| Multiple connections | One per contract | Use ILogicBlockModbusTcpClientFactory for multiple clients |
For connecting to multiple Modbus TCP devices from one logic block, inject ILogicBlockModbusTcpClientFactory and call Create() for each connection.
Register the package in your DependencyInjection class:
services.AddDaleModbusTcpSdk();HTTP Client
The Dale.Sdk.Http package provides ILogicBlockHttpClient — a non-blocking HTTP client for calling external APIs from logic blocks. All operations use callbacks dispatched through the actor system, so the logic block thread is never blocked.
[LogicBlockInfo("Weather Station")]
public class WeatherStation : LogicBlockBase
{
private readonly ILogicBlockHttpClient _http;
public WeatherStation(ILogger logger, ILogicBlockHttpClient http) : base(logger)
{
_http = http;
}
[ServiceProperty("Temperature", "°C")]
[ServiceMeasuringPoint("Temperature", "°C")]
public double Temperature { get; private set; }
[Timer(300)]
public void FetchWeather()
{
_http.GetJson<WeatherResponse>(
this,
"https://api.open-meteo.com/v1/forecast?latitude=47.37&longitude=8.55¤t=temperature_2m",
response => { Temperature = response.Current.Temperature2m; },
ex => Logger.LogWarning(ex, "Weather API error"));
}
}Available methods:
| Method | Description |
|---|---|
GetJson<T> | GET request, deserialize JSON response |
PostJson<TReq, TRes> | POST with JSON body, deserialize response |
PostJson<TReq> | POST with JSON body, no response body |
PutJson<TReq, TRes> | PUT with JSON body, deserialize response |
PutJson<TReq> | PUT with JSON body, no response body |
DeleteJson<T> | DELETE, deserialize response |
Delete | DELETE, no response body |
SendRequest | Raw HttpRequestMessage for full control |
All methods accept optional headers and timeout parameters. Register the package:
services.AddDaleHttpSdk();Cardinality and Sharing
Service provider contracts support cardinality and sharing options to control how many providers can be connected and whether they can be shared across blocks.
Cardinality
| Value | Description |
|---|---|
CardinalityType.Mandatory | Exactly one provider must be connected (default). |
CardinalityType.Optional | Zero or one provider may be connected. |
CardinalityType.Multiple | Multiple providers can be connected. |
Sharing
| Value | Description |
|---|---|
SharingType.Shared | Provider can be used by multiple blocks (default). |
SharingType.Exclusive | Provider is reserved for this block only. |
[ServiceProviderContract(
defaultName: "Sensor",
cardinality: CardinalityType.Optional,
sharing: SharingType.Exclusive)]
public IAnalogInput OptionalSensor { get; set; } = null!;Custom Service Providers
You can build custom service providers for any hardware, bus protocol, or external system. A service provider is a standalone process that communicates with the Dale runtime over the local MQTT broker using MQTT 5.0. It can be written in any language or technology — .NET, Python, Rust, CODESYS, TwinCAT, or bare-metal firmware.
See the Service Provider Protocol for the full MQTT protocol specification covering registration, declaration, health reporting, and service-specific messaging patterns.