Table of Contents

Doorbell Example

As a COVID lockdown project, I put together a custom doorbell controller using a Raspberry Pi and a couple relays. While this is a contrived, simplistic example, we will use this as a simple example for putting together a working rule-based workflow. The default processing engine in JobFlow uses NRules to define the job routes. JobFlow includes several helper classes to assist in writing JobFlow specific rules.

While using JobFlow for a doorbell is generally overkill, the ultimate goal is to integrate it with a home automation system. This example also demonstrates that it will run quite well on a system as small as a Raspberry Pi.

This example provides some visibility into the types of rules that can be created.

Writing the Rules

First, we have a piece of code listening to a GPIO pin on the Raspberry Pi which is connected to the doorbell button. I won’t go over that code specifically, suffice to say when the button is pressed, the code sends the following message to the job manager system:

{
    "Timestamp": "04-24-2021T10:00:00"
}

In the rule engine, that message will map to a DoorbellPressedMessage class:

[DocumentType("doorbell")]
public class DoorbellPressedMessage
{
    public DateTime TimeStamp { get; set; }
}

First, notice the attribute on the class - this indicates the class represents the document of type "doorbell". The system will detect this on startup, then automatically deserialize the incoming message to this type.

Initially, we’re simply going to ring the inside doorbell mechanism. Again, I won’t go into detail about that code – using another set of GPIO pins on the Raspberry Pi, it triggers a relay to ring the chime. The piece we need to create next is a rule to route the message to that chime-trigger handler.

[FlowRule("Doorbell")]
public class RingBellRule : JobFlowRule
{
    protected override void DefineRule()
    {
        When()
            .HaveJob(_ => _.WithNames(GlobalJobNames.Start));

        Then()
            .StartJob("RingBell");
    }
}
Tip

"Start" is the standard Job name the system uses when the work item processing first starts.

This rule makes use of the JobFlowRule class – it sets up a basic rule to take the result of one job and create the next in a standard workflow-style setup. In this case, we are looking at the result of the “Start” job. The “Start” job is a generic job step that is used when first starting processing. The job “RingChime” is the next job we want to execute. The JobFlowRule is set up to look for an dispatch configuration labeled "RingBell". Other classes other than JobFlowRule allow for much more configuration over how the rule is written.

At this point, we have a working doorbell controller. When the button is pushed, the system will trigger the chime function. However, I did not want to replace my existing doorbell with a more complicated system that accomplishes the same thing and simply rings a bell. So, the first thing I want to add is to limit how many times the bell will ring within a set period in order to limit solicitors from trying to play songs with my doorbell (yes, it has happened) – we will limit rings to once every 5 seconds.

public class DoorbellPressedMessage
{
    public DateTime TimeStamp { get; set; }
    public DateTime? PreviousTimeStamp { get; set; }
}

Note that the PreviousTimeStamp property is nullable, since in our case the button handler will not be passing in a value. In this example, we will be storing the previous value in the system cache instead. We could combine loading the previous value, comparing to the current timestamp, and then selectively creating a new job to ring the bell all into a single rule. However, to demonstrate some of the flexibility of the system, we will be splitting it into two rules.

We will start with the easier of the two rules to write – creating the “ring” job. To ring the bell only once every 5 seconds, we can use a filter on the rule itself:

[FlowRule("Doorbell")]
public class RingBellRule : JobFlowRule<DoorbellPressedMessage>
{
    protected override void DefineRule()
    {
        When()
            .HaveDocument(_ => _.Match(
                _ => _.Data.PreviousTimeStamp != null,
                _ => (_.Data.TimeStamp - _.Data.PreviousTimeStamp.Value).TotalSeconds > 5);

        Then()
            .StartJob("RingBell");
    }
}

We continue to use the JobFlowRule; however, we’re extending the Define method to incorporate a different filter. Since we have full access to the NRules engine, we can define additional rule criteria using the When method. The criteria we add states the rule will only fire if there is a DoorbellPressedMessage where the PreviousTimeStamp is not null and the difference between the TimeStamp and PreviousTimeStamp is less than 5 seconds. Note that in the constructor of the message, we don't match on the current job name and the job result status – we do not care what job executed before this or what its result was; we only care about the DoorbellPressedMessage and its contents. If those criteria are met, the JobFlowRule will create the "RingBell" job for us. In this way, we can define rules that are based on message data as opposed to traditionally defined rigid step-by-step workflows. (Note: The NRules documentation goes into more detail about how rules are defined.)

Now we will move on to writing the rule that loads the previous timestamp from cache. The complexity of this rules stems from the need to access the system cache which requires more extensive use of the NRules fluent API.

[FlowRule("Doorbell")]
public class LoadPreviousTimeStampRule : JobFlowRule<DoorbellPressedMessage>
{
    protected override void DefineRule()
    {
        Document<DoorbellPressedMessage> docuemnt = null;
        When()
            .IsStart()
            .HaveDocument(() => docuemnt, _ => _.Match(_ => _.Data.PreviousTimeStamp == null));
        ;
        ICacheManager cacheManager = null;
        Dependency()
            .Resolve<ICacheManager>(() => cacheManager);

        Then()
            .Do(ctx => LoadTimestamp(ctx, Logger, cacheManager, docuemnt));
    }

    private void LoadTimestamp(IJobFlowContext ctx, ILogger logger, ICacheManager cacheManager, Document<DoorbellPressedMessage> message)
    {
        var previous = cacheManager.GetOrAdd<DateTime>("Doorbell:PreviousPress", _ => DateTime.Now.AddDays(-1));
        message.Data.PreviousTimeStamp = previous;

        ctx.RuleContext.Update(message);
    }
}

In this rule, we create our own action to handle updating the "previous press" data.

First, we can see the additional matching criteria using then When method – in this case, the rule will only fire when the PreviousTimeStamp is null. We also only want the rule to run if the previous job was "Start" (that is, if the process just started). The Dependency call allows us to resolve additional dependencies from the IoC container – in this case, we are looking for the ICacheManager. The Then method allows us to specify what actions to take when the rule fires. The parameter being passed to the lambda method with Do is the NRule rule context which is an important addition for the needs of this rule. If we look at LoadTimestamp, we see the use of the ICacheManager to get the previous time (or return a time a day in the past if the cache does not have the previous timestamp).

Finally, we need to update the cache with the new timestamp. This requires another edit to the RingBellRule.

[FlowRule("Doorbell")]
public class RingBellRule : JobFlowRule<DoorbellPressedMessage>
{
    protected override void DefineRule()
    {
        ICacheManager cache = null;
        Dependency()
            .Resolve(() => cache);

        Document<DoorbellPressedMessage> document = null;
        When()
            .HaveDocument(() => document, _ => _.Match(
                _ => _.Data.PreviousTimeStamp != null,
                _ => (_.Data.TimeStamp - _.Data.PreviousTimeStamp.Value).TotalSeconds > 5);

        Then()
            .StartJob("RingBell")
            .Do(ctx => UpdateCache(ctx, cache, document));
    }

    private void UpdateCache(IJobFlowContext ctx, ICacheManager cache, Document<DoorbellPressedMessage>? document)
    {
        cache.Set("Doorbell:PreviousPress", document.Data.TimeStamp);
    }
}

We grab the ICacheManager again. This time, however, we use it to update the cache in the UpdateCache method. Notice that we are able to provide multiple actions to take via the Then() method.

Forward Rule Chaining

After updating the message, we call ctx.Update(message) – this important step tells NRules that the message was updated and NRules will then perform "forward-chaining" and find any new rules that match the updated data. Our first rule, which is looking for a non-null PreviousTimeStamp property may now match and fire. The final line updates the cached value to the current doorbell press timestamp.

See NRules Forward Chaining for more information.

Writing the Job Handlers

Azure Functions

We need only a single handler as our scenario current exists - something to ring the actual doorbell chime. The logic of the handler is beyond the scope of this document, suffice to say it uses two more GPIO pins to trigger a relay. What we are concerned with is the general format for a job handler. In this case, we are using a standard Azure Function with a Storage Queue as the trigger.

Tip

To learn more about Azure Functions, see Introduction to Azure Functions

Note

The Transport system that's used by JobFlow is defined in the Tranports section of JobFlow's Messaging options. See Messaging Options for more information.

public static class DoorbellHandler
{
    [FunctionName("DoorbellHandler")]
    [return: Queue("job-result", Connection = "JobQueueConnectionString")]
    public static Message<JobWorkResponse> Run(
        [QueueTrigger("ring-chime", Connection = "JobQueueConnectionString")]
        Message<JobWorkRequest<DoorbellPressedMessage>> jobModelMsg, 
        ILogger log)
    {
        log.LogInformation("Received message");

        var job = jobModelMsg.Item;
        DoRingChime(job.Data);

        var result = JobWorkResponse.FromRequest(job, JobResultStatus.Success);

        return new Message<JobWorkResponse>(result);
    }
}

As we can see, this Function is listening to queue named "ring-chime." The DoRingChime method returns a WorkResponse, which indicates whether the job succeeded or failed. The return value for the Function gets placed on the "job-result" queue. The message data being sent in and out of the Function is stored with the envelope class Message<T> with the request data using the WorkRequest model.

Other Handlers

Queue listeners and base job message processor classes are provided to allow for writing handlers in other ways. The queue listeners act as BackgroundServices in ASP.Net Core, which allows messages to be processes from within standard ASP.Net applications and App Services.

Future updates will provide queue listeners that aren't tied to ASP, allowing for Containers and other non-web technologies to act as workers as well.