package com.example.DemoApp;
public class DemoApp extends SystemApplication<BaseAppConfig> {
@Override
public void load() {
}
@Override
public void start() {
}
}
One of the core design goals of KOS is scalability, but what does this mean in the context of a smart dispense device?
Scalability in KOS refers to the ability to use the same software stack to build a basic Legacy+ dispenser with an SBC (single board computer), or a dispenser with multiple nozzles, or a dispenser with multiple SBCs, or even a distributed dispenser with parts located in both the front and back rooms. This application note demonstrates how to build a simple legacy dispenser using the KOS building blocks.
This note focuses on the steps required to perform simple ingredient pour. It leaves beverage pouring to a different note. It also ignores issues of brandsets and ingredient assignments, which are covered in a separate note. This note does not cover hardware integration, and simply simulates pump operation, since it’s a hypothetical dispenser.
While KOS supports a flexible application model, during the boot process it will look for a special application called the "system" application. This application is packaged in a KAB file and is listed in the NodeManifest
. We won’t cover all of the details of how to package and install a system application in this note.
All KOS applications:
extend the SystemApplication (primary app) or Application (secondary app) class
define the appId
and reference the appClass
in the descriptor.json
file
The system app must have an appId
of "system". The sample below is an example of an empty system application named DemoApp
:
package com.example.DemoApp;
public class DemoApp extends SystemApplication<BaseAppConfig> {
@Override
public void load() {
}
@Override
public void start() {
}
}
Notice this application extends SystemApplication instead of Application. This provides access to several convenience methods typically used by system applications.
When packaged into a KAB file the descriptor.json
file needs to populate the app
section of the descriptor as follows:
{
"kos": {
"app": {
"appClass": "com.example.DemoApp",
"appId": "system"
}
}
}
The appClass
entry tells KOS which class to load to start the application, and the appId
defines the unique application ID for this application. It must be "system" when it’s a system app.
Applications have several lifecycle events including:
load()
Called once and only once when the application is loading. Here, you set up any beans and state that should survive until the application is unloaded. This includes tasks like: instantiating your beans and adding them to the bean context; opening all databases; mounting KAB resources; etc. When this method returns:
Beans that were added to the BeanContext are autowired
All ConfigAware beans are configured
All @ApiController beans added to the context are processed (endpoints are enabled when the application is started)
Databases are ready to access
start()
Called when the application is starting. This happens both at initial start up and during live updates, which means that start/stop cycles can be performed multiple times. When called, your application can start performing any normal actions. Endpoints and VFS mounts are available when this returns.
stop()
Called with the application is stopping. This is not only called before unloading, but is also called during live system updates. When called, the application should prepare to be unloaded, or to have any other applications unloaded. All endpoints and VFS mounts are disabled before this is called, which prevents external events from triggering while in a stopped state.
unload()
Called once and only once when the application is unloading. This should clean up all resources and references so that the application ClassLoader can be unloaded.
System applications are never stopped or unloaded. In those cases, only load()
and start()
are implemented.
A dispenser pours beverages and ingredients by controlling pumps. This example works with valves, but uses the term pump interchangeably.
Pumps vs Valves
Strictly speaking, pumps have the ability to monitor and control flow rates, while valves are either simply on or off. |
Pumps are usually electro-mechanical devices that have driver circuitry and hardware to turn the pumps on and off. This hardware typically exists in the form of an external board that connects to the SBC using some transport, and implements some protocol for controlling the pumps.
Within KOS, the abstraction for external hardware is the Board class. Applications extend this class to model and interface to the external hardware using a "native code adapter". This app note ignores these details, but assumes we have an external board containing the following:
One plain water valve with expected flow rate of 74 ml/sec, named PW
One carbonated water valve with expected flow rate of 74 ml/sec, named CW
Four syrup valves with expected flow rates of 15 ml/sec, named Sx
Furthermore, we assume that the control board uses a simple protocol that turns a valve on and off based on its position. We assume the valves have the following positions:
PW : 0
CW : 1
S1 : 2
S2 : 3
S3 : 4
S4 : 5
To build a dispenser, we’re going to need an actual Pump class to work with. Since we’re modeling simple valves, we’ll name our pump class DemoValve
. Here is our initial class:
public class DemoValve extends Pump<PumpConfig> {
@Getter @Setter // creates getConfig() and setConfig() methods
private PumpConfig config; // configuration data for the pump
private int pos; // position of the valve on the board
public DemoValve(Board board, int pos, String name, String category) {
super(board, name, category);
this.pos = pos;
config = new PumpConfig();
// Default syrup rate of 15ml/sec:
getConfig().setNominalRate(15);
}
@Override
public FutureWork tpour(int duration, double rate) {
return null;
}
@Override
public FutureWork vpour(int volume, double rate) {
return null;
}
@Override
public String getType() {
return "demo.valve";
}
}
Lombok
The |
Note that Pump is a generic class that requires a configuration class. We simply use the base PumpConfig class, as it contains a single nominalRate
property, which signifies the pump’s normal flow rate. The nominal rate is used when no flow rate is specified in pour operations. As these are valves and the flow rates are mechanically calibrated, it might seem odd to define a nominal flow rate. But even when calibrating mechanically, there is a need to have a target flow rate based on different concentrations of BiB ingredients. The nominal rate in the pump config can serve as the target rate for this use case, as all configuration settings (including pump configurations) can be manipulated using the KOS ConfigService.
A Pump requires a board
, name
, and category
:
This is the circuit board the pump is attached to. In a relay system, the board forwards pour requests to the pump’s control hardware.
Many objects in KOS require a name, which is used for display and handle path purposes. A "handle path" is a string that uniquely identifies a software object.
Used to match PumpIntent definitions. For now, we ignore this by passing in null
.
Since Pump implements ConfigAware, we must implement the getConfig()
and setConfig()
methods. In our code above, this is accomplished using the Lombok annotations. However, we must allocate the config object. Although some valves will have a nominal rate of 15ml/sec and others 74ml/sec, we’ll simply use 15 and override the value for water valves when we create them.
Every pump must perform these two basic operations, which used as building blocks for more sophisticated operations.
tpour()
Timed pour for the specified duration (in msec) at the specified rate (in ml/sec). For a valve, the rate is effectively ignored. As we will see, since we’re using valves, the rate is usually simply the configured nominal rate.
vpour()
Volume pour for the specified volume (in ml) at the specified rate in (ml/sec). As valves have no flow meter and their rate is fixed, there is seemingly no way to implement this. However, if the pump is calibrated to the nominal rate, then we can compute the time from the volume and rate, thus vpour()
can simply call tpour()
.
Both of these return a FutureWork object, which is a KOS construct for managing asynchronous work. We touch on this briefly, but for a more in-depth description, please see the Javadoc entry.
Now that we have a pump class, we need to define a board to interface to the hardware and define the pumps. Based on the list of valves from above, the DemoBoard
class is implemented as:
@Getter
public class DemoBoard extends Board {
private DemoValve waterValve; // plain water valve
private DemoValve carbValve; // carbonated water valve
private List<DemoValve> syrups; // syrup valves
public DemoBoard(Assembly assembly, String name) {
super(assembly, name);
// Create the water pumps:
waterValve = new DemoValve(this, 0, "pw", null);
carbValve = new DemoValve(this, 1, "cw", null);
// Adjust the nominal rates of water valves:
waterValve.getConfig().setNominalRate(74);
carbValve.getConfig().setNominalRate(74);
// Create the four syrup pumps:
syrups = new ArrayList<>();
for (int i = 1; i <= 4; i++) {
syrups.add(new DemoValve(this, i + 1, "s" + i, null));
}
}
@Override
public String getType() {
return "demo.board";
}
}
Note that we adjust the nominal rate of the water valves after they are created. Since the pumps are ConfigAware, they will be configured at a later state, but prior to use, so the values set here will be considered the default values and any overrides will be applied automatically.
The board must implement getType()
, which returns the type of the board. This can be any string, but it will be matched up with the type returned from the native code adapter that interfaces to the hardware. The HardwareService within KOS matches these together and notifies the board that it is connected to the hardware. We are ignoring this in this app note.
As described above, a request to run a pump will ultimately result in the pump forwarding the request to the board, which forwards the request to the underlying hardware. Now that we have our board and pump classes, let’s simulate a pour and connect the pumps to the board.
The first step is to simulate a tpour()
in the board since we know we can convert a vpour()
to a tpour()
in the pump class (described above). Here is an implementation of tpour()
that can be added to the board:
tpour()
method:public class DemoBoard extends Board {
// . . .
public FutureWork tpour(int pos, int duration) {
FutureWork future = new FutureWork("tpour", f -> {
// Open the valve (but just log it now):
log.info("opened valve: {}", pos);
// Timer to mark when tpour successfully finishes:
KosUtil.scheduleCallback(() -> f.success(), duration);
}, duration);
// When complete, turn the valve off. This is called for success or cancel:
future.append("closeValve", FutureEvent.COMPLETE, (f) -> log.info("closed valve: {}", pos));
return future;
}
// . . .
}
This method creates a FutureWork, and in the runnable portion logs that the valve is open (instead of sending a command to hardware). It then schedules a callback for the duration of the pour and in the callback marks the Future "successful". This marks the future "complete", where the callback appended to the Future fires and logs that the valve is closed (instead of sending the command to hardware).
This app note is not an in-depth study of FutureWork, but it is worth noting that the scheduled callback doesn’t turn the valve off: we only turn the valve off as a side effect of the Future completing. This is because a user (or internal process) may decide to cancel the pour before it completes. In this case, the future completes earlier than expected, but we still want to turn the valve off. The original callback still fires and marks the Future "successful", but it has already reached a "cancelled" end state, so the call to success()
is ignored. This illustrates that through careful use of FutureWork, it is quite easy to handle success and error recovery with very little effort.
Now that we have tpour()
in the board, we can fill in tpour()
and vpour()
in the DemoValve
class:
tpour()
and vpour()
methodspublic class DemoValve extends Pump<PumpConfig> {
// . . .
@Override
public FutureWork tpour(int duration, double rate) {
return ((DemoBoard)getBoard()).tpour(pos, duration);
}
@Override
public FutureWork vpour(int volume, double rate) {
// Compute time from volume and then call tpour (multiply by 1000 to get msec from ml/sec):
return tpour((int)((volume * 1000) / rate), rate);
}
// . . .
}
KOS maintains logical representations of hardware and various physical components as objects. We’ve seen the Board class, which represents an external board that interfaces to the Pump class, which logically represents the physical pumps. KOS also has logical representations of other key components of a dispenser, even if they only exist in logical form for some dispensers:
A nozzle gathers a collection of pumps together and manages various pipelines that can implement different ways to pour from the nozzle.
This is a logical representation of a physical container that holds one or more ingredients (Freestyle has cartidges with more than one ingredient in them). For a dispenser that uses BiB ingredienes, the actual BiB box is the physical equivalent of a container. Water also requires a logical container even though there is no phyisical equivalent. In more complex systems, the container may contain data and be an active component (Freestyle cartridges contain RFID tags, for example).
This is a logical representation of a receptical that an ingredient container is inserted into. In most systems, this is a logical concept only, as BiB ingredients are simply connected with a passive hose. In some systems, the holder may have a physical form and some capabilities. For example, Freestyle dispensers have cartridge slots, and some of those have indicator lights and agitators.
KOS knows how to manage all these entities, but doesn’t know how everything is connected. Part of the responsibility of the system application is to create these objects and connect them together in a way that reflects the actual hardware of the dispenser.
Another feature of KOS is that groups of hardware can be atomically added and removed on the fly. It also supports the concept of optional hardware. Here is a brief description of optional hardware versus expansion hardware:
Generally speaking, if a board can be added after the fact to an existing dispenser, or a board may come and go (such as a diagnostic device), then it can be defined as optional, and it will just work when discovered.
Generally speaking, an expansion is a collection of new hardware such as an expansion tower where there can be one or more, may define new nozzle, and have their own group of internal hardware that must exist together.
Optional vs Expansion Hardware
It’s not critical to understand the distinction between "optional" and "expansion" hardware for this app note, but it is worth noting that an expansion is defined by an Assembly, which can define complex collections of hardware, including additional nozzles, boards, pumps, and so on, whereas optional hardware exists logically in the system, but remains hidden until it is detected. |
A key requirement of the system application is to create an Assembly, which contains all the logical components of the dispenser, and then install that assembly into KOS. Let’s have a look at creating an assembly class for our demo dispenser:
public class DemoAssembly extends CoreAssembly {
public DemoAssembly() {
// Create a nozzle and add it to the assembly:
Nozzle nozzle = new Nozzle(this, "myNozzle");
add(nozzle);
// Create a board and add it to the assembly:
DemoBoard board = new DemoBoard(this, "myBoard");
add(board);
// Add the pumps from the board to the nozzle:
nozzle.add(board.getWaterValve());
nozzle.add(board.getCarbValve());
nozzle.add(board.getSyrups());
// Create the water holders:
add(new Holder(this, "PW", board.getWaterValve()));
add(new Holder(this, "CW", board.getCarbValve()));
// Create the syrup holders:
int i = 1;
for (Pump<?> pump : board.getSyrups()) {
add(new Holder(this, "S"+(i++), pump));
}
}
@Override
public void install() {
}
}
Notice that in addition to creating the board, we also added a Nozzle and attached the pumps to it. This is because we pour using the nozzle, so it needs to know the connected pumps. We also added the holders which will connect ingredients to the pumps (we will skip ingredient assignment in this app note).
It’s worth noting that holders are "globally" scoped, whereas pumps are "nozzle" scoped. Holders tend to be the objects displayed in dashboards, as they represent the named points in the dispenser that the ingredients are attached to. As a result, the holders are created with display names for the associated pumps.
While this simple dispenser has a single pump per holder, KOS also handles more complex architectures where a single holder may have more than one pump, each routed to a different nozzle.
This assembly extends CoreAssembly instead of Assembly. It is a KOS convention that the main assembly for the dispenser is named "core". By extending CoreAssembly, the name is set according to this convention.
When creating an assembly, the constructor creates the bulk of the objects, and then adds them to the assembly by calling add()
. This adds the objects to the BeanContext of the assembly, which inherits from the system application context, which inherits from the root KOS context. This allows objects created in the constructor to be autowired to any components in the assembly, system application, or KOS itself.
Now that we have an assembly, we can go back to our system application and fill in the implementation:
DemoAssembly
into our application:public class DemoApp extends SystemApplication {
@Override
public void start() {
installAssembly(new DemoAssembly());
}
}
When the assembly is installed, KOS autowires all the associated context, configures all ConfigAware beans, and calls install()
on the assembly object. At this point, all of the beans are fully wired and configured, so they can be used for any additional device-specific logic. For example, this is where listeners and events are connected so that button presses cause pours, door switches block ice pours, or whatever custom logic is required.
At this point we have boards installed, pumps connected to a nozzle, and the system is ready to go. However, there is currently no way to actually pour. In order to pour from a nozzle, the nozzle needs at least one NozzlePipeline installed.
A NozzlePipeline is an abstraction for a method of pouring. A pipeline focuses on ingredient or beverage pouring. It is possible for a nozzle to have multiple pipelines installed, and each pipline provides unique endpoints for pouring in whatever way that pipeline defines. The nozzle ensures that only one pipeline can pour at a time, which prevents underling hardware from getting mixed signals from the various pipelines.
There are two pipelines included in KOS:
Supports ingredient-based pouring. This is the primary pipeline used for administrative functions such a priming, purging, and calibrating.
Supports beverage pouring based on a user-defined brandset. This supports computation of beverage availability based on the state of the dispenser, and is aligned with models provided in the UI SDK which provide simple end-to-end beverage selection and pouring. Higher level pour operations can easily be stacked on top of this pipeline.
For the sake of brevity, this application note will only examine IngredientNozzlePipeline, as it doesn’t require defining a brandset.
Dispenser administration functions tend to include many operations that result in an ingredient pour. Consider functions like prime, purge, calibration, hold to pour, etc.
In a Freestyle machine, many of these operations are complex sequences of operations, some of which have over 100 discreet steps. Each of these have a logical purpose. For example, a calibration pour has a distinct purpose relative to priming, and the definition of these pours are likely different. A calibration pour may be a 200ml pour, while a prime is something completely different.
In addition, while pours vary based on the intention of the pour, the same intention may differ by pump. For example, calibration for water may be 200ml, whereas calibration for a syrup may be 50ml. This raises the question of how to manage all of these different types of pours in order to minimize the logic required in application code, and to prevent these requirements from leaking into the UI code base.
The IngredientNozzlePipeline solves this problem by introducing the concept of pump "intents". A pump intent corresponds to a particular type of pour, such as a calibration pour.
The intent can be given a name, such as "calibration", and then a data file can be used to define the sequence of operations that defines that intent. It is then possible to define multiple variations of the named intent and differentiate them by pump type, pump handle path, or pump category. In the steps below, we install the pipeline and then define some intents so that we can perform a calibration pour on the water pumps with a volume of 200ml, but for syrup pumps use a volume of 50ml, and see that there is a single endpoint to pour these and the pipeline will sort out the details and perform the necessary pour.
To install a pipeline, we simply add it to the nozzle after we create it in the Assembly.
public class DemoAssembly extends CoreAssembly {
public DemoAssembly() {
// . . .
// Add pump pipeline:
nozzle.add(new IngredientNozzlePipeline(intentFactory));
// . . .
}
}
The pipeline is designed to use a factory which returns a PumpIntent, given a pump and intent name. A PumpIntent is simply a sequence of PumpOp objects, each of which describes a single operation that the pump should perform. The sequence of operations then defines the entire intent, which is treated as a single atomic operation at the application layer. Based on the fact that every pump supports tpour()
and vpour()
, KOS supports the following pump operations out of the box:
TpourOp
: Performs a tpour on the pump with the specified parameters
VpourOp
: Performs a vpour on the pump with the specified parameters
DelayOp
: Performs a delay before starting the next operation
User applications can extend PourOp and define additional operations specific to the underlying hardware. Freestyle hardware supports more than a dozen different operations.
Any implementation of PumpIntentFactory can be used by the pipeline, but KOS provides XmlPumpIntentFactory, which loads intents and operations from XML files. This class can be extended to allow parsing of custom operations. You can also extend the PumpIntent class itself to hold additional application-specific information, if required. For this application note our pumps only support tpour and vpour, so we can use XmlPumpIntentFactory without any changes.
For this example we will also use the stock PumpSeqenceFactory, which simply converts a PumpIntent to a pump sequence that can be executed by the pipeline.
public class DemoAssembly extends CoreAssembly {
public DemoAssembly() {
// . . .
// "Intent" factory for use by pump pipeline:
intentFactory = new XmlPumpIntentFactory();
intentFactory.addLoader(new ClassLoaderResourceLoader(getClass().getClassLoader()));
intentFactory.load("intents.xml");
// Add pump pipeline:
nozzle.add(new IngredientNozzlePipeline(intentFactory));
// . . .
}
}
XmlPumpIntentFactory loads intents from XML files (which can include other XML files, allowing for inheritance and other conveniences in managing intents). In the example above we add a resource loader that looks for the specified XML file as a resource file in the JAR. We then specify the file we want to load the intents from the "intents.xml" file.
Next we define the two calibration intents: one for water valves and one for syrup valves. Using the XML file we define these as follows:
<?xml version="1.0" encoding="UTF-8"?>
<pumpIntents>
<intents>
<intent name="water_calibrate">
<op type="vpour" volume="200"/>
</intent>
<intent name="syrup_calibrate">
<op type="vpour" volume="50"/>
</intent>
</intents>
</pumpIntents>
We have now defined two intents, one named "water_calibrate" and the other "syrup_calibrate". Next, we define rules to apply them to the correct pumps. There are three types of rules defined in XmlPumpIntentFactory, although application code can add support for additional rules and change the way the rules are evaluated. These are the three supported rule types:
pumpPath
: This attaches an intent to a pump using the handle path of the pump. This is the most specific rule available and has the highest priority.
pumpCategory
: This attaches an intent to a pump using the category of the pump. This is second in priority.
pumpType
: This attaches an intent to a pump based on the type of the pump. This is the lowest priority.
In our example all pumps are of the same class, which means they all have the same type (see DemoValve.getType()
). If we use this type of rule, we’ll pick up all valves. However, the water valves should be different from the syrup valves. We start with this rule and try to address the water valves shortly. Here is the rule added to the XML file:
<?xml version="1.0" encoding="UTF-8"?>
<pumpIntents>
<intents>
<intent name="water_calibrate">
<op type="vpour" volume="200"/>
</intent>
<intent name="syrup_calibrate">
<op type="vpour" volume="50"/>
</intent>
</intents>
<rules>
<rule type="pumpType" key="demo.valve">
<intent type="calibrate" ref="syrup_calibrate"/>
</rule>
</rules>
</pumpIntents>
This rule attaches "syrup_calibrate" to all pumps of type "demo.valve", which is every valve. We now want to override this rule for the water and carbination pump. Based on the available rule types, we can either: a) add a rule for each pump, specifying the handle path to each pump, or b) use the pump category to pick up both pumps at the same time. First we need to go back to our DemoBoard
class where we created the water pumps and specify a category such as "water" instead of using null
:
public class DemoBoard extends Board {
public DemoBoard(Assembly assembly, String name) {
super(assembly, name);
// . . .
// Create the water pumps:
waterValve = new DemoValve(this, 0, "pw", "water")); // now passing in "water" instead of null
carbValve = new DemoValve(this, 1, "cw", "water"));
// . . .
}
}
Now that these pumps have a category, we can add a rule to match them:
<?xml version="1.0" encoding="UTF-8"?>
<pumpIntents>
<intents>
<intent name="water_calibrate">
<op type="vpour" volume="200"/>
</intent>
<intent name="syrup_calibrate">
<op type="vpour" volume="50"/>
</intent>
</intents>
<rules>
<rule type="pumpType" key="demo.valve">
<intent type="calibrate" ref="syrup_calibrate"/>
</rule>
<rule type="pumpCategory" key="water">
<intent type="calibrate" ref="water_calibrate"/>
</rule>
</rules>
</pumpIntents>
The pipeline is now fully configured for this one intent.
If we compile all this code and run it in KOS, we can access the IngredientPipelineService within the IngredientNozzlePipeline and query an intent for a given pump. The endpoint is related to the name we provided for the nozzle since the pipeline is scoped to the nozzle:
GET /api/nozzle/{nozzleName}/pipeline/ingredient/intent/{pumpPath}/{intent}
If we want to find the handle paths for our pumps, we can use HandleService to list all the handles in the system:
GET /api/handles
In the list that is returned we find:
... assembly.core.holder:PW.pump:pw assembly.core.holder:S1.pump:s1 ...
If we query the "calibrate intent" for the plain water pump, we get the following:
GET /api/nozzle/myNozzle/pipeline/ingredient/intent/assembly.core.holder:PW.pump:pw/calibrate
{
"status": 200,
"version": {
"major": 1,
"minor": 0
},
"data": {
"name": "water_calibrate",
"ops": [
{
"volume": 200,
"rate": 0
}
],
"source": "intents.xml"
}
}
If we perform the same query using a syrup pump, we get the following:
GET /api/nozzle/myNozzle/pipeline/ingredient/intent/assembly.core.holder:S1.pump:s1/calibrate
{
"status": 200,
"version": {
"major": 1,
"minor": 0
},
"data": {
"name": "syrup_calibrate",
"ops": [
{
"volume": 50,
"rate": 0
}
],
"source": "intents.xml"
}
}
We can now construct a user interface that performs a calibration pour for any valve. The UI button simply needs to pass the pump handle path along with an intent of "calibrate", at which point the pump will perform the correct type of pour. This provides a number of interesting advantages over embedding pour information into the UI:
It is now possible to write code that is independent of the underlying hardware. The pumps can be discovered using endpoints and the intents are fixed by UI function, but the underlying operations performed can be swapped out as data from one platform to another. Within Freestyle, we have a single admin interface that runs on every model of dispenser.
With all pump intents visible via endpoints, verifying that a pump is performing the correct operations is as easy to hitting an endpoint in a browser. These same endpoints can be used in test automation to verify all pumps perform the correct operations for all possible intents without ever hitting a pour button.
It’s pretty rare that the software team defines the specifics of any particular pour. The more types of pours there are and the more complex the UI, the harder it is for the software team to manage the implementation. At Freestyle, the intents files are managed by the fluidics team and are pulled into the build as data.
Hopefully this application note provided insight into using KOS to set up a basic dispenser with ingredient pouring support. If you have any questions or feedback for this note, please contact the KOS development team.