Devices are like micro hosted services with a name. They can be running or not (Started/Stopped) and (re)configured dynamically.
Device implementation heavily relies on Commands. Their execution is isolated in an asynchronous loop.
All devices are configured (see DeviceConfiguration) and can be reconfigured dynamically.
The configuration can specify that the device must be AlwaysRunning
and a daemon automatically
ensures that they are restarted when stopped (typically due to an unexpected error).
By installing the optional CK.DeviceModel.Configuration package, configurations from the standard .Net configuration API (appsettings.json and so on) are automatically and dynamically applied from the CK-DeviceModel root configuration section.
See also: Device, Command, The 5 basic commands, ActiveDevice, DeviceHost, Daemon, CK.DeviceModel.Configuration package.
Logs emitted by this library are tagged with "Device-Model"
(exposed by IDeviceHost.DeviceModel
) and this
tag is configured in the ActivityMonitor.Tags.DefaultFilters
to clamp logs to 'Monitor' LogFilter (log groups in Trace
and log lines in warning).
Devices can support any number of specific methods (devices can be seen as multiple instances of micro IHostedService that support dynamic reconfiguration), but their implementations SHOULD only be helpers that send Commands.
Any device implementation MUST rely on Commands. This is the only way to prevent concurrency issues.
Key features that Commands support are:
- Command execution is serialized thanks to channels and an internally managed asynchronous command loop with its own ActivityMonitor.
- Commands can generate a result (see DeviceCommand<TResult>) or not (see DeviceCommand).
- Commands that are handled while the device is stopped can be considered as errors, be canceled, be executed anyway or deferred until the device starts again (see the DeviceCommandStoppedBehavior enumeration).
- Commands can be sent immediately (highest priority) or delayed, waiting for their
SendingTimeUtc
. - Commands can have one timeout (in milliseconds that is computed and starts right before the command is handled) but can also be bound to any number of CancellationTokens.
- Commands completion MUST be signaled explicitly.
- Commands may transform errors or cancellations into command results. The BaseReconfigureDeviceCommand is an example where errors or cancellation are mapped to DeviceApplyConfigurationResult enumeration values.
- Completed commands (even the ones that are completed outside of the command loop and regardless of their state - error, canceled or success)
can be safely "continued" thanks to the Device's
OnCommandCompletedAsync
method. AddReminder
andOnReminderAsync
enable a Device to easily trigger and postpone the execution of any code (including completing one or more command).
More details on Command can be found here.
There are 2 kind of devices:
- the basic ones are passive: they handle commands and emit only events related to their lifetime (status, configuration and controller key changes)
- the active devices extends basic ones and are able to emit specific events either in response to commands or because the actual/physical device they interface is itself active.
Implementing a new device requires to define its host and its configuration types. Here is a complete implementation of a simple passive device (a flash bulb):
- The host definition (that may offer a specific API if needed, but nothing more that this is required).
public sealed class FlashBulbHost : DeviceHost<FlashBulb, DeviceHostConfiguration<FlashBulbConfiguration>, FlashBulbConfiguration>
{
}
- The device's configuration: its name MUST end with
Configuration
(and be in the same namespace and assembly). TheDoCheckValid
method is optional but default constructor and binary serialization support are required:
public sealed class FlashBulbConfiguration : DeviceConfiguration
{
/// <summary>
/// A default public constructor is required.
/// </summary>
public FlashBulbConfiguration()
{
}
public int FlashColor { get; set; }
public int FlashRate { get; set; } = 1;
protected override bool DoCheckValid( IActivityMonitor monitor )
{
bool isValid = true;
if( FlashColor < 0 || FlashColor > 3712 )
{
monitor.Error( $"FlashColor must be between 0 and 3712." );
isValid = false;
}
if( FlashRate <= 0 )
{
monitor.Error( $"FlashRate must be positive." );
isValid = false;
}
return isValid;
}
/// <summary>
/// Deserialization constructor.
/// Every specialized configuration MUST define its own deserialization
/// constructor (that must call its base) and override the <see cref="Write(ICKBinaryWriter)"/>
/// method (that must start to call its base Write method).
/// </summary>
/// <param name="r">The reader.</param>
public FlashBulbConfiguration( ICKBinaryReader r )
: base( r )
{
r.ReadByte(); // version
FlashColor = r.ReadInt32();
FlashRate = r.ReadInt32();
}
/// <summary>
/// Symmetric of the deserialization constructor.
/// Every Write MUST call base.Write and write a version number.
/// </summary>
/// <param name="w">The writer.</param>
public override void Write( ICKBinaryWriter w )
{
base.Write( w );
w.Write( (byte)0 );
w.Write( FlashColor );
w.Write( FlashRate );
}
}
- The device itself. This is a minimal definition without any Command. It holds the current color to use and tracks whether its value is from the configuration.
public sealed class FlashBulb : Device<FlashBulbConfiguration>
{
int _color;
bool _colorFromConfig;
public FlashBulb( IActivityMonitor monitor, CreateInfo info )
: base( monitor, info )
{
_color = info.Configuration.FlashColor;
_colorFromConfig = true;
}
protected override Task<DeviceReconfiguredResult> DoReconfigureAsync( IActivityMonitor monitor,
FlashBulbConfiguration config )
{
bool colorChanged = config.FlashColor != CurrentConfiguration.FlashColor;
bool configHasChanged = colorChanged || config.FlashRate != CurrentConfiguration.FlashRate;
if( colorChanged && _colorFromConfig )
{
_color = config.FlashColor;
}
return Task.FromResult( configHasChanged
? DeviceReconfiguredResult.UpdateSucceeded
: DeviceReconfiguredResult.None );
}
protected override Task<bool> DoStartAsync( IActivityMonitor monitor, DeviceStartedReason reason )
{
return Task.FromResult( true );
}
protected override Task DoStopAsync( IActivityMonitor monitor, DeviceStoppedReason reason )
{
return Task.CompletedTask;
}
protected override Task DoDestroyAsync( IActivityMonitor monitor )
{
return Task.CompletedTask;
}
}
- Defining two commands (one without result and one with a result).
/// <summary>
/// This command triggers a flash on the bulb.
/// </summary>
public sealed class FlashCommand : DeviceCommand<FlashBulbHost>
{
}
/// <summary>
/// This command sets the color of the flash (or resets it to the configured
/// color) and returns the previous color.
/// </summary>
public sealed class SetFlashColorCommand : DeviceCommand<FlashBulbHost,int>
{
/// <summary>
/// The new color to set.
/// Null to reset the color to the <see cref="FlashBulbConfiguration.FlashColor"/>.
/// </summary>
public int? Color { get; set; }
}
- The implementation of the two commands:
protected override async Task DoHandleCommandAsync( IActivityMonitor monitor,
BaseDeviceCommand command,
CancellationToken token )
{
switch( command )
{
case FlashCommand f:
// ...Do whatever is needed here to make the bulb flash using
// the current _color and CurrentConfiguration.FlashRate...
f.Completion.SetResult();
return;
case SetFlashColorCommand c:
{
var prevColor = _color;
if( c.Color != null )
{
_color = c.Color.Value;
_colorFromConfig = false;
}
else
{
_color = CurrentConfiguration.FlashColor;
_colorFromConfig = true;
}
c.Completion.SetResult( prevColor );
return;
}
}
// The base.DoHandleCommandAsync throws a NotSupportedException: all defined
// commands MUST be handled above.
await base.DoHandleCommandAsync( monitor, command, token ).ConfigureAwait( false );
}
There is much more to say about Commands: see here.
- Finally, a simple helper that triggers a flash directly on the device: such specific device API must always be simple helpers that eventually send a command (and await its completion).
public async Task<bool> FlashAsync( IActivityMonitor monitor )
{
var cmd = new FlashCommand();
if( !UnsafeSendCommand( monitor, cmd ) )
{
// The device has been destroyed.
return false;
}
// Wait for the command to complete.
await cmd.Completion.Task.ConfigureAwait( false );
return true;
}
- To conclude, an example of the appsettings.json configuration file for such devices (this is automatically handled whenever the CK.DeviceModel.Configuration package is installed):
{
// The root section must be CK-DeviceModel.
"CK-DeviceModel": {
// Hosts are found thanks to their name.
"FlashBulbHost": {
// Optionally, hosts can have their own configuration if needed, but by default
// only the Items (the devices) are handled.
"Items": {
// Devices are named objects.
"FlashBulb n°1":
{
"Status": "AlwaysRunning",
"FlashColor": 45,
"FlashRate": 100
},
"FlashBulb n°2":
{
"Status": "RunnableStarted",
"ControllerKey": "WebAPI"
"FlashColor": 12,
},
"Another FlashBulb":
{
"Status": "Disabled",
"FlashColor": 12,
}
}
}
}
}