Skip to main content

Your first machine

info

This tutorial is aimed at beginners that have read previous tutorials about publishing and querying events!

Our goal is to develop a simple Actyx machine for an imaginary client, allowing you to get your feet wet with Actyx.

In this scenario, the client has a flower planted in a smart pot, the smart pot keeps track of the water level and if it gets too low, emits a warning; the client wants us to develop a robot that receives the warning and waters the plant.

Setup

Requirements

Before starting the tutorial, you will need to have NodeJS, npm and npx installed (if it is not installed, you can take a look over the installation guide).

Let's start by creating our project folder and adding its dependencies.

First, initialize the NPM package. We'll use -y as the questions NPM asks are not relevant to the current project.

$ npm init -y

We also need to install TypeScript as a development dependency and initialize the TypeScript project.

$ npm install typescript --save-dev
$ npx tsc --init

The last command will generate tsconfig.json of which we will change the outDir to dist/ just so our compiled JavaScript code isn't mixed with the TypeScript code.

tsconfig.json
{
"compilerOptions": {
// ...
"outDir": "dist/"
//...
}
}

We're almost done with setup, we just need to add the Actyx packages now!

$ npm install @actyx/sdk @actyx/machine-runner

Application structure

Before we start coding, let's (quickly) discuss file structure.

When developing a protocol, there will be a bunch of code that can be reused throughout the application. To smooth things out in the next steps, let me share with you the code structure we will use.

As shown in the picture, we will create three files:

  • robot.ts - our robot's entrypoint
  • sensor.ts - our (mock) sensor's entrypoint
  • protocol.ts - the protocol that defines the events, etc, that the robot and sensor will use.
$ ni -Path '.\src\robot.ts' -ItemType File -Force
$ ni -Path '.\src\sensor.ts' -ItemType File -Force
$ ni -Path '.\src\protocol.ts' -ItemType File -Force

The Robot

As discussed, our task is to develop a robot that waters a plant whenever needed. The idea is as follows — the plant has a sensor that checks the soil humidity level, if said level goes below a certain threshold, it sends the robot an event requesting water, when the level is restored, the sensor sends an event signaling that the robot can stop.

info

To further simplify our example, we will not care about the amount of water we're providing to the plant. Instead, we will focus on the interaction between the machine and events.

Defining the application manifest

Spoiler alert, this will only be used later on, but we'll need it for both the robot and the sensor, so we'll get it out of the way now.

The application manifest is used by Actyx to authenticate your application (you can read more about it in the how-to guides and in the conceptual guides). The manifest requires an application ID, a display name, and an application version, nothing too out of the ordinary:

src/protocol.ts
export const manifest = {
appId: 'com.example.watering-robot',
displayName: 'watering Robot',
version: '1.0.0',
}

That's it, with that out of the way, let's move on to the actual protocol.

Defining the protocol

To define a protocol, we start by creating its events:

src/protocol.ts
import { MachineEvent, SwarmProtocol } from '@actyx/machine-runner'

export namespace Events {
export const HasWater = MachineEvent.design('HasWater').withoutPayload()
export const NeedsWater = MachineEvent.design('NeedsWater').withoutPayload()
export const All = [HasWater, NeedsWater] as const
}

export const protocol = SwarmProtocol.make('wateringRobot', Events.All)

Let's break down what is happening:

  1. We create a namespace for our events to keep everything together and easy to access:
export namespace Events { ... }
  1. We then declare the protocol events (exporting them so we can actually use them outside the namespace):
export const HasWater = MachineEvent.design('HasWater').withoutPayload()
export const NeedsWater = MachineEvent.design('NeedsWater').withoutPayload()

MachineEvent.design creates an event with the name you pass it, however, since events may also carry payloads, we finish it off by applying either withPayload or withoutPayload. For simplicity's sake, we will use withoutPayload.

  1. Finally, we create All. It isn't an event but it will be useful when registering the events on the protocol. You can also create other groups of events!
export const All = [HasWater, NeedsWater] as const
  1. We create the SwarmProtocol that the robot will use.:
export const protocol = SwarmProtocol.make('wateringRobot', Events.All)

SwarmProtocol.make takes the name of the protocol and all events that compose it (this is why All is useful).

Note on SwarmProtocol

Actyx is designed to support both a single machine or multiple ones. When working with multiple machines, you have a swarm, all machines in the swarm must "sync" in a way or another so they can cooperate instead of getting in each others' way, that's where the Swarm in SwarmProtocol comes in.

Open to see the full contents of protocol.ts
src/protocol.ts
import { MachineEvent, SwarmProtocol } from '@actyx/machine-runner'

export const manifest = {
appId: 'com.example.tomato-robot',
displayName: 'Tomato Robot',
version: '1.0.0',
}

export namespace Events {
export const HasWater = MachineEvent.design('HasWater').withoutPayload()
export const NeedsWater = MachineEvent.design('NeedsWater').withoutPayload()
export const All = [HasWater, NeedsWater] as const
}

export const protocol = SwarmProtocol.make('wateringRobot', Events.All)

Creating the robot

Our robot is a state machine and as the name implies, it needs states.

We start by creating the Machine, the main abstraction which manages all the states.

src/robot.ts
import { protocol } from './protocol'

export const machine = protocol.makeMachine('robot')

Followed by the machine's states:

src/robot.ts
export const Idle = machine.designEmpty('Idle').finish()
export const WateringPlant = machine.designEmpty('WateringPlant').finish()

Just like the events, our states don't carry any data (though they can as you will see in the next tutorials), thus they make use of designEmpty.

Finally, to tie everything together, we need to handle the events, in other words, we need to make our robot react to them.

To do so, we need to describe what each event reacts to using react:

src/robot.ts
import { Events } from './protocol'

Idle.react([Events.NeedsWater], WateringPlant, (_) => WateringPlant.make())
WateringPlant.react([Events.HasWater], Idle, (_) => Idle.make())

react takes in three arguments:

  1. A list of events it should react to (another place where grouping states is useful).
  2. The next state.
  3. A function that handles the state transition. This function receives the previous state as a parameter; in our case, since we're only handling a single state which has no data, there is no need to care for the parameter.

Our robot needs to be able to run, otherwise, it's a bit useless. To do so, we need an entrypoint:

src/robot.ts
import { Actyx } from '@actyx/sdk'
import { createMachineRunner } from '@actyx/machine-runner'
import { manifest } from './protocol'

export async function main() {
const app = await Actyx.of(manifest)

const tags = protocol.tagWithEntityId('robot-1')
const machine = createMachineRunner(app, tags, Idle, undefined)

for await (const state of machine) {
console.log(state)
}
}

main() // Execute your entrypoint

It's a bit more code than before, but it's easier, let's see:

  1. This is your Actyx application instance, it will connect to Actyx and authenticate your app.
const app = await Actyx.of(manifest)
  1. This is a tag, it is used to know who's who — e.g. if you have a bunch of robots that all have the same model name, you can tag them with their serial number.
const tags = protocol.tagWithEntityId('robot-1')
  1. Create your robot's state machine runner. The runner will listen and react to events with the tags defined in the previous step. Its parameters are:
  • The application manifest.
  • The machine's tags.
  • The machine's initial state.
  • The machine's initial payload, we declare it as undefined since our state does not carry data.
const machine = createMachineRunner(app, tags, Idle, undefined)
  1. The main loop, where you get new states as they go through:
for await (const state of machine) {
console.log(state)
}
Open to see the full contents of robot.ts
src/robot.ts
import { createMachineRunner } from '@actyx/machine-runner'
import { Actyx } from '@actyx/sdk'
import { Events, manifest, protocol } from './protocol'

const machine = protocol.makeMachine('robot')

export const Idle = machine.designEmpty('Idle').finish()
export const WateringPlant = machine.designEmpty('WateringPlant').finish()

Idle.react([Events.NeedsWater], WateringPlant, (_) => WateringPlant.make())
WateringPlant.react([Events.HasWater], Idle, (_) => Idle.make())

export async function main() {
const sdk = await Actyx.of(manifest)
const tags = protocol.tagWithEntityId('robot-1')
const machine = createMachineRunner(sdk, tags, Idle, undefined)

for await (const state of machine) {
console.log(state)
}
}

main()

Running the robot

To run the robot we need to compile its code and run it using NodeJS, to simplify subsequent runs, we'll add a new script to package.json:

package.json
"scripts": {
"start-robot": "tsc && node dist/robot.js"
}

Which enables you to just run npm run start-robot.

Ensure Actyx is running!

Need a refresher on how to start Actyx? See how-to guide.

So, let's run it!

$ npm run start-robot

> [email protected] start-robot
> tsc && node dist/robot.js

{
is: [Function: is],
as: [Function: as],
cast: [Function: cast],
payload: undefined,
type: 'Idle'
}
caution

Actyx keeps data between runs, so if you stop Actyx, everything from previous runs will still be there.

To ensure that this (and subsequent) tutorial's code executes properly, you should stop Actyx and clear its storage between runs. To do so, you can run remove the actyx-data folder.

$ rm -R .\actyx-data

So yeah, nothing happens, and that is because there is no one publishing events! Our mock sensor does not exist yet, so let's write it (I swear it's fast).

The sensor

The sensor is much simpler, as we're just publishing events, we just need to prepare the Actyx SDK and start publishing.

src/sensor.ts
import { Actyx } from '@actyx/sdk'
import { Events, manifest, protocol } from '.'

async function main() {
const app = await Actyx.of(manifest)
const tags = protocol.tagWithEntityId('robot-1')

await app.publish(tags.apply(Events.NeedsWater.make({})))
console.log('Publishing NeedsWater')
await app.publish(tags.apply(Events.HasWater.make({})))
console.log('Publishing HasWater')

app.dispose()
}

Once more, breaking it down:

  1. We've gone over this for the robot, same rules apply here.
const app = await Actyx.of(manifest)
const tags = protocol.tagWithEntityId('robot-1')
  1. Publish the events (with some logs since publish does not print anything to the console)!
await app.publish(tags.apply(Events.NeedsWater.make({})))
console.log('Publishing NeedsWater')

await app.publish(tags.apply(Events.HasWater.make({})))
console.log('Publishing HasWater')
  • First, we create a new event with Events.NeedsWater.make({}).
  • Then, we apply the existing tags to it with tags.apply.
  • Finally, we publish the event to the app with app.publish.
  1. Since our demo does not run forever, we need to dispose of the app.
app.dispose()
Open to see the full sensor.ts
src/sensor.ts
import { Actyx } from '@actyx/sdk'
import { Events, manifest, protocol } from '.'

async function main() {
const sdk = await Actyx.of(manifest)
const where = protocol.tagWithEntityId('robot-1')

await app.publish(tags.apply(Events.NeedsWater.make({})))
console.log('Publishing NeedsWater')

await app.publish(tags.apply(Events.HasWater.make({})))
console.log('Publishing HasWater')

sdk.dispose()
}

main()

As a final touch, just like we added an npm script for the robot, we should do the same for the sensor:

package.json
"scripts": {
"start-robot": "tsc && node dist/robot.js",
"start-sensor": "tsc && node dist/sensor.js"
}

Putting it all together

Now we have everything prepared!

  1. Ensure Actyx is running.
  2. Start the robot using npm run start-robot.
  3. In another terminal, start the sensor using npm run start-sensor.

If we take a look into the robot terminal, we should observe the following:

{
is: [Function: is],
as: [Function: as],
cast: [Function: cast],
payload: undefined,
type: 'Idle'
}
{
is: [Function: is],
as: [Function: as],
cast: [Function: cast],
payload: undefined,
type: 'WateringPlant'
}
{
is: [Function: is],
as: [Function: as],
cast: [Function: cast],
payload: undefined,
type: 'Idle'
}

If you notice, the state changes from Idle to WateringPlant and back, which means that the robot successfully listened to the event from the sensor and watered the plant! 🌱

In summary

In this tutorial we covered how to get up and running with the Actyx Machine Runner.

We started by creating a robot that listens for events pertaining a plant's water level, when necessary, the robot will water the plant and stop when an acceptable water level is detected.

Afterward, we developed a mock sensor that publishes the events to Actyx, which consequently hands them to the robot, so it can act on the plant's water needs.

All this runs on top of Actyx (which coordinates all underlying events between the participants), the SDK (which we use to publish the events) and the Machine Runner (which allows us to write the state machine for the robot).