Updated Release of the Abodit State Machine
I published a new version of the Abodit State Machine to Nuget this evening. You can find it here.
One breaking change in this version is that the state machine is now specified using three Type parameters instead of two:
public class OccupancyStateMachine :
StateMachine<OccupancyStateMachine, Event, BuildingArea>
The third type parameter, TContext
, is a context object that can be
passed in with every event occurrence or tick. This means that you don't
need to store any extraneous data in the state machine itself and can
keep it as a pure representation of the state of the system (SRP). So in
designing a state machine based on this code, bear that in mind - the state
tracks which state it is in and not all the other properties of the object
it controls or responds to. TContext
might be an ILight
for example and
all of the physical state and properties like: on, off, brightness, minimum
brightness, ramp rate, linger time, ... belong in ILight
and the state
machine tracks only the intended states like: On
, OnManual
, OnAutomatic
,
Off
, OffManual
, OffAutomatic
, Dimming
.
In the example above I have an OccupancyStateMachine
and the context is
a BuildingArea
. Each call to EventHappens
now takes the event that
happened and a BuildingArea
object.
When you define your state machine you will need to include four parameters in each lambda expression.
Here, for example, is the current state machine for a BuildingArea
in my
home automation. It uses a hierarchy of states with two base states: Not Occupied
and Occupied
. It has timers for activity within a room or for
occupancy within rooms that are contained by a floor. Note how it also
exposes an IObservable<State>
so that other objects can subscribe
to state machine changes. I didn't want to take the Rx dependency in the
state machine class itself but you can see how easy it is to hook it up.
Of interest also is the way I represent occupancy as three distinct
states, the extra one Asleep
represents a room that is not-occupied in
the sense that there is no motion there now but there was at some point
during the evening before.
public class OccupancyStateMachine : StateMachine<OccupancyStateMachine, Event, BuildingArea>
{
private static readonly ILog log = LogManager.GetLogger("OccState");
[NonSerialized]
private readonly Subject<State> watch = new Subject<State>();
public IObservable<State> Watch => watch.AsObservable();
public override void OnStateChanging(StateMachine<OccupancyStateMachine, Event, BuildingArea>.State newState, BuildingArea context)
{
watch.OnNext(newState);
}
[NonSerialized]
private readonly Subject<Event> watchEvents = new Subject<Event>();
public IObservable<Event> WatchEvents => watchEvents.AsObservable();
public override void OnEventHappened(Event @event)
{
watchEvents.OnNext(@event);
}
//public static readonly State Starting = AddState("Starting");
public static readonly State NotOccupied = AddState("Not occupied",
(m, e, s, c) => {
m.CancelScheduledEvent(eTick); // Stop the clock
c.IsTimerRunning.Current = false;
c.IsHeavilyOccupied.Current = false;
m.After(new TimeSpan(0,30,0), eHalfHourPostOccupancy );
m.After(new TimeSpan(0,60,0), eOneHourPostOccupancy );
},
(m, e, s, c) => { });
public static readonly State Asleep = AddState("Asleep",
(m, e, s, c) =>
{
// Set a timer going for morning
var now = TimeProvider.Current.Now.LocalDateTime;
var morning = now.Hour < 8 ? now.AddHours(-now.Hour + 8) : now.AddHours(24 - now.Hour + 8);
m.CancelScheduledEvent(eMorning);
m.At(morning.ToUniversalTime(), eMorning);
},
(m, e, s, c) => { },
parent:NotOccupied);
public static readonly State Occupied = AddState("Occupied",
(m, e, s, c) =>
{
m.CancelScheduledEvent(eTimeout);
m.After(c.OccupancyTimeout, eTimeout); // start a new timeout
c.IsTimerRunning.Current = true;
// Add a timer that runs while we are occupied
m.CancelScheduledEvent(eTick); // remove any old eTick events
m.Every(new TimeSpan(hours:0, minutes:0, seconds:10), eTick);
// And kill any post occupancy timers
m.CancelScheduledEvent(eHalfHourPostOccupancy);
m.CancelScheduledEvent(eOneHourPostOccupancy);
m.CancelScheduledEvent(eMorning);
},
(m, e, s, c) => { });
/// <summary>
/// Room is occupied and all doors into it are enclosed
/// </summary>
public static readonly State OccupiedAndEnclosed = AddState("Occupied and enclosed",
(m, e, s, c) => { },
(m, e, s, c) => { },
parent: Occupied);
private static readonly Event eStart = new Event("Starts");
private static readonly Event eUserActivity = new Event("User activity");
// maintains occupancy, does not set it
private static readonly Event eSensorActivity = new Event("Sensor activity");
// 10s tick while occupied
private static readonly Event eTick = new Event("Tick");
private static readonly Event eDoorOpens = new Event("Door opens");
// All doors closed
private static readonly Event eDoorCloses = new Event("Door closes");
private static readonly Event eTimeout = new Event("Timeout");
private static readonly Event eMorning = new Event("Morning");
private static readonly Event eTimeoutHalfHourPostOccupancy = new Event("Timeout Half Hour");
private static readonly Event eTimeoutOneHourPostOccupancy = new Event("Timeout One Hour");
private static readonly Event eAllChildrenNotOccupied = new Event("No child occupied");
private static readonly Event eAtLeastOneChildOccupied = new Event("At least one child occupied");
// Public events that we expose
public static readonly Event eHalfHourPostOccupancy = new Event("Half hour post occupancy");
public static readonly Event eOneHourPostOccupancy = new Event("One hour post occupancy");
static OccupancyStateMachine()
{
// Note: This is a hierarchical state machine so NotOccupied includes Asleep
NotOccupied
.When(eAtLeastOneChildOccupied, Occupied)
.When(eDoorOpens, Occupied)
.When(eUserActivity, (m, s, e, c) =>
{
if (c.Enclosed.Current)
return OccupiedAndEnclosed;
else
return Occupied;
}
);
// Asleep is a substate of not occupied so no need for more logic on becoming occupied ...
Asleep
.When(eMorning, NotOccupied);
// Occupied includes recently occupied and heavily occupied ...
Occupied
.When(eUserActivity, (m, s, e, c) =>
{
m.CancelScheduledEvent(eTimeout); // cancel the old timeout
m.After(c.OccupancyTimeout, eTimeout); // start a new timeout
c.IsTimerRunning.Current = true;
if (c.Enclosed.Current)
return OccupiedAndEnclosed;
else
return s;
})
.When(eSensorActivity, (m, s, e, c) =>
{
m.CancelScheduledEvent(eTimeout); // cancel the old timeout
m.After(c.OccupancyTimeout, eTimeout); // start a new timeout
c.IsTimerRunning.Current = true;
return s;
})
.When(eDoorCloses, (m, s, e, c) =>
{
// Tricky, it closes but unless there is motion who knows what state we are in????
return s;
})
.When(eAllChildrenNotOccupied, (m, s, e, c) =>
{
if (c.IsTimerRunning.Current)
{
// If the timer is running ... wait until it runs out
return s;
}
else
{
// recursion??? m.EventHappens(eTimeout, c);
// otherwise ...
if (c.Time.EveningTo6AM.On)
return Asleep;
else
return NotOccupied;
}
})
.When(eTick, (m, s, e, c) =>
{
return s;
})
.When(eTimeout, (m, s, e, c) =>
{
c.IsTimerRunning.Current = false;
// No action if we have occupied children
if (c.HasOccupiedChildren.Current)
return s;
// No timeout if occupied and enclosed ???
if (s == OccupiedAndEnclosed)
{
c.AddToLog("Kept occupied because enclosed");
m.After(c.OccupancyTimeout, eTimeout); // start a new timeout
return s;
}
if (c.Time.EveningTo6AM.On)
return Asleep;
else
return NotOccupied;
});
OccupiedAndEnclosed
.When(eDoorOpens, (m, s, e, c) =>
{
return Occupied;
})
.When(eTick, (m, s, e, c) =>
{
return s;
});
}
public OccupancyStateMachine()
: base(NotOccupied)
{
}
public override void Start()
{
log.Debug("Start");
this.EventHappens(eStart, null);
}
public void UserActivity(BuildingArea buildingArea)
{
string was = this.CurrentState.ToString();
this.EventHappens(eUserActivity, buildingArea);
log.Debug("User activity " + was + " -> " + this.CurrentState.ToString());
}
public void DoorOpens(BuildingArea buildingArea)
{
this.EventHappens(eDoorOpens, buildingArea);
}
public void DoorCloses(BuildingArea buildingArea)
{
this.EventHappens(eDoorCloses, buildingArea);
}
public void SensorMaintainingActivity(BuildingArea buildingArea)
{
var was = this.CurrentState;
this.EventHappens(eUserActivity, buildingArea);
if (was != this.CurrentState)
log.Debug($"Sensor maintaining occupancy state {was} -> {this.CurrentState}");
}
public void AllChildrenNotOccupied(BuildingArea buildingArea)
{
var was = this.CurrentState;
buildingArea.HasOccupiedChildren.Current = false;
this.EventHappens(eAllChildrenNotOccupied, buildingArea);
if (was != this.CurrentState)
{
log.Debug($"{buildingArea.Name} all children not occupied " + was + " -> " + this.CurrentState);
}
}
public void AtLeastOneChildOccupied(BuildingArea buildingArea)
{
var was = this.CurrentState;
buildingArea.HasOccupiedChildren.Current = true;
this.EventHappens(eAtLeastOneChildOccupied, buildingArea);
if (was != this.CurrentState)
{
log.Debug($"{buildingArea.Name} at least one child occupied " + was + " -> " + this.CurrentState);
}
}
public override string ToString()
{
return "Occupancy state: " + this.CurrentState.Name;
}
}