0.7.0 • Published 3 years ago

sorrir-framework v0.7.0

Weekly downloads
4
License
Apache-2.0
Repository
-
Last release
3 years ago

About

This project develops a minimum valuable prototype for SORRIR.

Component Types

overview

Instances

  • inductionSensor
  • payment
  • parkingGarageManagementSystem
  • barrier
  • entrance (SensorBox)
  • exit (SensorDisplayBox)
  • plateRecognitionService

Open Issues

  • why is the "sensor" part of the barrier modelled separately while the actuator part is not?
  • do we need two inductionSensors (in and out)
  • why would entrance and exit use different ports at prs?

Deployment

Open Issues

  • what is our hardware landscape?
  • where do physical components have their digital twin located?

    • remote via wires?
    • remote via wireless (Bluetooth, ZigBee, WLan, ...)
    • in one device?
    • microcontroller? "real" CPU?
  • inductionSensor: hardware component wired to the barrier. when emulating it, it needs to be collocated with the inductionSensor service

  • barrier: hardware/software component for letting cars pass in both directions
  • plateRecognitionService: could be a remote service, could be a local service, could be a hardware enclosure
    • stateless, easy to replicate
    • can be scaled and easily replaced
  • payment: could be a remote (cloud) service, even offered by a third party
  • parkingGarageManagementSystem: could be a remote service, could be a local service
    • stateful, state most likely in a separate database
    • can be made stateless -> easy to scale

Software Installation

In the following we will explain the main concepts of the SORRIR framework by a step-by-step walkthrough.

Preparations

If this is your first TypeScript project, you may need to install nodejs and an IDE of your choice, e.g., WebStorm, VSCode, or VSCodium (same as VSCode but without all the Microsoft tracking crap).

Clone the projects containing the SORRIR framework mvp and the testbed into a directory of your choice, e.g. sorrir/.

Inside sorrir/mvp-simple-testbed/, open tsconfig.json. The testbed project locally imports the SORRIR framework as a symlink (see last line). Because of this, it is necessary to build the framework first. Run from your command line npm run-script build-framework. You have to do this only once or after you made changes to the framework project.

{
  "name": "mvp-simple-testbed",
  "version": "0.1.0",
  "description": "",
  "main": "app.ts",
  "scripts": {
    "build": "tsc",
    "start": "tsc && node dist/app.js",
    "build-framework": "cd ../mvp && npm run-script build"
  },
  "dependencies": {
    "tslint": "^6.1.0",
    "typescript": "^3.8.3",
    "@types/node": "^12.12.34",
    "sorrir-framework": "file:../mvp"
  }
}

Content of package.json

NEW: if you do not plan to contribute to the development of the SORRIR framework, you can easily import the framework via npm, too. Therefore, add "sorrir-framework": "^0.3.4" to the dependencies section of your package.json.

Finally, install dependencies of the testbed project: npm install.

SORRIR Framework Overview

From the top level view, your application consists of components. To communicate or to be more precise send events from one component to another component, components can be connected through ports.

The internal behavior of a component is defined by a state machine. In consequence, each component has several states and transitions.

The internal engine executes the behavior in step-wise. In every step, each component is executed once. Resulting events will be delivered in the next step.

Example: Smart Parking Garage

Framework

We start building our testbed by specifying the barrier component step-by-step followed by specifying parkingManagement component. Furthermore, we explain how to integrate MQTT I/O components.

Components

npm.io

To create a new component, you have to describe its states, the external events it can handle, the ports through which it can be connected to other components, its internal state, and a state machine to describe its behavior.

States

barrier has two states IDLE and CAR_ENTRY. Define the state of a component within its *.ts file – in this case Barrier.ts.

enum BarrierStates {
    IDLE = "IDLE",
    CAR_ENTRY = "CAR_ENTRY"
}

Ports

Since ports are used to connect a component to another component, it is a good naming pattern to start the name of port with its direction followed by the target or target group. Events can be sent and received only to components that are connected to a component.

In the current version of the SORRIR framework prototype a port's direction is just a naming convention. It is possible to send an event through port FROM_MY_COMPONENT whereas its name would indicate the other way round. In a future version, we will ensure a correct behavior within the framework.

barrier is connected to barrierController via port FROM_BARRIER_CONTROLLER and to parkingManagement via port TO_PARKING_MANAGEMENT. You have to define the ports of a controller as an enum:

export enum BarrierPorts {
    TO_PARKING_MANAGEMENT = "TO_PARKING_MANAGEMENT",
    FROM_BARRIER_CONTROLLER = "FROM_BARRIER_CONTROLLER"
}

Events

Since events are sent across various components of your application, their types should be defined in a central file events.ts. Event types must be defined as an enum:

export enum EventTypes {
    BUTTON_UP_PRESSED = "BUTTON_UP_PRESSED",
    BUTTON_DOWN_PRESSED = "BUTTON_DOWN_PRESSED",
    CAR_IN = "CAR_IN",
    CAR_OUT = "CAR_OUT",
    LED_RED = "LED_RED",
    LED_GREEN = "LED_GREEN",
    DISPLAY = "DISPLAY"
}

In this example, we use a single event enum for the whole application. If your application grows, you can think of splitting it into several more specific event enums.

The events that are passed from component to component or within a component are typed by the EventTypes you specified in your enum. This way a component can react depending on the type of the event it has received. In general, an Event looks like this:

export interface Event<E, P, D> {
	readonly type: E,
	readonly port?: P,
	readonly payload?: D
}

If you do not specify a port, the event is an internal event of your component. Currently, payload? is optional and only used for MQTT events.

InternalState

A component also has an internal state that is passed from step to step. It should be readonly to remind you, that you can not change an internal state. During the execution of a step,

@todo mit Matthias abklären, wie das nun eigentlich richtig ist

Define your internal state as a type alias containing all the attributes you need for your component:

type BarrierState = {
    lastAction : number;
}

The internal state of barrier holds a single attribute lastAction: number. We use this attribute to save the last timestamp a car passed the barrier (or a button was pressed) to ensure that this can happen only once per second.

Behavior

You describe the behavior of a component as a state machine. You have to pass all the states, ports, event types, and the internal state you defined before as type parameter to the state machine:

const sm : StateMachine<BarrierStates, BarrierState, EventTypes, BarrierPorts> = {
    transitions: [...]
};

How, when, and under what conditions the state machine can switch from a state to another state is expressed by a transition. The interface of a transition looks as follows:

export interface Transition<F, M, E, P, D> {
	readonly sourceState: F,
	readonly event?: [E,P?],
	readonly condition?: (myState: M, event?:Event<E, P, D>) => Boolean,
	readonly action?: (myState: M, raiseEvent:RaiseEventCallBack<E, P, D>, event?: Event<E, P, D>) => M,
	readonly targetState: F,
}

As you can see, at least you have to define a source and a target state. You can react to a specific event (otherwise the transition is activated automatically). You also can define a guard (condition) which ensures that the transition is activated only if a user defined condition is satisfied. Furthermore, you can define an action function that is executed if the transition is activated. Within this action function, you can update the internal state of the state machine or raise new events.

As you can see in the overview image of barrier above, if it is in state IDLE and receives one of the events BUTTON_UP_PRESSED or BUTTON_DOWN_PRESSED via port FROM_BARRIER_CONTROLLER it should send CAR_IN or CAR_OUT correspondingly to parkingManagement via port TO_PARKING_MANAGEMENT and switch to state CAR_ENTRY. Let's see how this works by creating a new transition inside the state machine:

...
transitions: [
	{
		sourceState: BarrierStates.IDLE,
	  	targetState: BarrierStates.CAR_ENTRY
	}
]
...

This is the most basic transition. To activate it only on event BUTTON_DOWN_PRESSED you have to add:

{
	sourceState: BarrierStates.IDLE,
  	targetState: BarrierStates.CAR_ENTRY,
  	event: [EventTypes.BUTTON_DOWN_PRESSED, BarrierPorts.FROM_BARRIER_CONTROLLER]
}

Besides specifying the event that can activate that transition, you also have to define the port through which the event will be received. As you may noticed, you only can choose from events you have specified and passed as parameter to the state machine before.

To raise a new event, you can specify an action function:

{
	...
	action: (myState, raiseEvent, event) => {
		raiseEvent({ type: EventTypes.CAR_OUT, port: BarrierPorts.TO_PARKING_MANAGEMENT});
		return { lastAction: Date.now() };
   }
	...
}

Your action function gets the internal state, raiseEvent, and the occurring event. raiseEvent is a callback function to raise a new event. It expects an Event as parameter. The interface of Event requires the attribute type and optional port and payload (see above). You can call raiseEvent as many times you need to.

The last line { lastAction: Date.now() } updates the internal state by setting the current timestamp. We need this timestamp to simulate the time a car takes to pass the barrier. barrier should only switch back from CAR_ENTRY to IDLE after 1s. The corresponding transition looks as follows:

{
            sourceState: BarrierStates.CAR_ENTRY,
            targetState: BarrierStates.IDLE,
            condition: myState => myState.lastAction + 1000 <= Date.now(),
            action: myState => {
					// action is actually not required
					return myState;
            }
}

The condition ensures that this transition can be activated only if the last action happened at least 1000 ms back in time.

During the time barrier is in CAR_ENTRY, all incoming button events will not be processed but stored and processed when barrier is switched back to IDLE.

To complete the definition of barrier you have to define a transition for event BUTTON_UP_PRESSED correspondingly to BUTTON_DOWN_PRESSED.

Component

Next, all things are grouped into a Component. Unfortunately, the current version of the framework requires all elements of the ports enum to be listed manually and passed as argument to createStatemachineComponent(...). sm is the state machine you created before and "barrier" is the name of that component:

export const barrier:Component<EventTypes, BarrierPorts> = createStatemachineComponent(
    [BarrierPorts.TO_PARKING_MANAGEMENT, BarrierPorts.FROM_BARRIER_CONTROLLER],
    sm,
    "barrier",
);

Initial State

Finally, you have to describe the initial state of your component. In our case the start state is set to IDLE, the internal state to lastAction: 0 and we did not put any prepared event in the event queue.

export const barrier_startState:StateMachineState<BarrierStates, BarrierState, EventTypes, BarrierPorts> = {
    state: {
        fsm: BarrierStates.IDLE,
        my: {
            lastAction: 0
        }
    },
    events: []
};

Component parkingManagement

npm.io

You can find parkingManagement inside ParkingManagement.ts. parkingManagement consist of the states AVAILABLE (there are free parking spaces in the parking garage) and FULL (no free parking spaces left). It is connected via port FROM_BARRIER to barrier and via port TO_SIGNAL_CONTROLLER to signalController I/O component.

On every transition this component sends three events to the signalController: LED RED on or off, LED GREEN on or off, and the remaining free parking spaces. Depending on the received car event from barrier the number of free parking spaces (stored in the internal state of parkingManagement) is in- or decreased. While there are free parking spaces left, parkingManagement stays in AVAILABLE. If there are no spaces left, it switches to FULL.

Regarding to the way MQTT works, we raise three single events for each of the hardware components we want to control:

...
{
	sourceState: ParkingManagementStates.AVAILABLE,
	targetState: ParkingManagementStates.FULL,
	event: [EventTypes.CAR_IN, ParkingManagementPorts.FROM_BARRIER],
	condition: myState => myState.freeParkingSpaces - 1 == 0,
	action: (myState, raiseEvent) => {
		const updatedFreeParkingSpaces = myState.freeParkingSpaces-1;

		raiseEvent({type: EventTypes.LED_RED, port: ParkingManagementPorts.TO_SIGNAL_CONTROLLER, payload: {status: true}});
		raiseEvent({type: EventTypes.LED_GREEN, port: ParkingManagementPorts.TO_SIGNAL_CONTROLLER, payload: {status: false}});
		raiseEvent({type: EventTypes.DISPLAY, port: ParkingManagementPorts.TO_SIGNAL_CONTROLLER, payload: {freeSpaces: updatedFreeParkingSpaces}});

		return {
			freeParkingSpaces: updatedFreeParkingSpaces,
			totalParkingSpaces: myState.totalParkingSpaces
		};
	}
},

There is a small but important difference to the events we raised from barrier. This time, the raised events have a payload.

raiseEvent({type: EventTypes.LED_GREEN, port: ParkingManagementPorts.TO_SIGNAL_CONTROLLER, payload: {status: false}});

To enable a state machine to handle events with a payload, you have to specify an additional payload type at the state machine definition (see last parameter):

const sm : StateMachine<ParkingManagementStates, ParkingManagementState, EventTypes, ParkingManagementPorts, MQTTPublishPayload> =
{ ... }

You can specify any arbitrary data type or interface as payload. In our case, the payload looks like this:

export interface MQTTPublishPayload {
    status? : boolean,
    freeSpaces? : number
}

The remaining things to do are similar to barrier.

In the next section, we describe how MQTT I/O components can be specified.

MQTT Components

We use the MQTT protocol to communicate with the sensor nodes. MQTT follows the publish/subscribe pattern and is a lightweight machine-to-machine protocol for the Internet of Things.

There are two kinds of MQTT components which the SORRIR framework offers out-of-the-box. A MQTTReceiveComponent and a MQTTSendComponent. Both MQTT components of our testbed are declared in MQTTComponents.ts and are described in the following.

MQTT requires a MQTT broker. You can run a broker at your own, for example Mosquitto, or use one that is public available, e.g., mqtt://test.mosquitto.org. Whatever you decide to do, you must add your MQTT credentials to params.ts:

export const params = {
	...
    mqttURL:                "mqtt://hassio.local",      // URL of your MQTT broker
    mqttUser:               "mqtt",                     // username and password for MQTT broker if needed
    mqttPw:                 "top-secret",     			// otherwise leave empty
	...    
}

In consequence, during the start of your application each MQTT component connects to the MQTT broker what could take some time.

BarrierController

Since MQTT components are just components as the two components described before, you need to define ports to connect them with other components. This is done in our case in MQTTPorts.ts:

export enum barrierControllerPorts {
    TO_BARRIER = "TO_BARRIER"
}

You do not need to explicitly specify a behavior via a state machine for a MQTT receive component. On every MQTT message the component receives, it calls your decode function (see below). If your function returns an event, the component will send it through the specified port.

You only need create the component by calling

export const barrierController = createMQTTReceiveComponent(mqttBarrierControllerName, barrierControllerPorts.TO_BARRIER);

In contrast to a regular component you even do not need to create a start state by hand, instead, call createMQTTReceiveComponentState(...) with the following parameters:

  • name:string the MQTT receiver will subscribe to a topic named like that. In our example, we set the topic to sorrir/button-pressed. Be careful: currently you have to ensure by hand that this topic is the same as you specified on the microcontroller.
  • brokerUrl: string URL of your MQTT broker.
  • opts?: IClientOptions is taken by the underlying MQTT library. In our case, we specify username, password, and clientId. For all possible attributes, we refer to the official documentation of MQTT.js.
  • decode? : (mqttPayload: string) => Event<E, P, D> | undefined is the function you have to write to decode the MQTT payload into a corresponding event of your application. Microcontroller buttonMcu (the one with the buttons connected to) sends to the topic you defined either payload BUTTON_UP_PRESSED or BUTTON_DOWN_PRESSED regarding the pressed button. In consequence, the resulting decode function is:
const decode = (payload: string) => {
    if (payload === "BUTTON_UP_PRESSED" || payload === "BUTTON_DOWN_PRESSED") {
        return {port: barrierControllerPorts.TO_BARRIER, type: payload };
    }
}

The full call to create a MQTT receive component looks as follows:

const opts = { username: params.mqttUser, password: params.mqttPw};

export const barrierController = createMQTTReceiveComponent(mqttBarrierControllerName, barrierControllerPorts.TO_BARRIER);
export const barrierControllerState = createMQTTReceiveComponentState(mqttButtonTopic, params.mqttURL, {...opts, clientId: mqttBarrierControllerName}, decode);

Configuration

Dieser MR ermöglicht das Konfigurieren des MVP mittels Konfigurationsdateien, Umgebungsvariablen und Command-Line Parametern. Diese Konfiguration wird anhand eines dynamisch generierten Schemas validiert. Dieses dynamisch generierte Schema basiert auf den spezifizierten Einheiten von Komponenten (units) und dient unter anderem dazu, für Konfigurationsoptionen eine Umgebungsvariable zu definieren. Im Beispiel ist das a,b,c. Das Schema ist in config.ts https://gitlab-vs.informatik.uni-ulm.de/sorrir/mvp/-/merge_requests/3/diffs#diff-content-b8b899f35cf16068ad3bc72c690c0c0e4a006b7b beschrieben.

Somit werden zunächst die units definiert und anschließend konfiguriert.

Während der Entwicklung und solange NODE_ENV auf development gesetzt ist, wird config/development.json geladen, in Production stattdessen config/production.json.

Die Struktur in der die Konfiguration angegeben werden kann, hat sich nicht geändert, allerdings können components nicht mehr als Referenz angegeben werden, sondern müssen über ihren Namen definiert werden. Dieser Name wird nach dem Laden der Konfiguration in Referenzen geändert: https://gitlab-vs.informatik.uni-ulm.de/sorrir/mvp/-/merge_requests/3/diffs#46fd997888257ab206d5050f71f671888d6f90b3_55_38 Diese Implementierung besitzt sicherlich noch Verbesserungspotenzial :slight_smile:

Das Erstellen von Multi Architecture Container Images ist auch Teil dieses MR, da ich die Konfigurationen direkt im Testbed auf verschiedenen Architekturen getestet habe. Die Notwendigkeit dazu bezieht sich vor allem auf die build dependencies durch node_modules und die notwendigen Metadaten im Container Image. Letzteres wird durch das (experimentelle) docker buildx erreicht. Das Deployment ist in https://gitlab-vs.informatik.uni-ulm.de/sorrir/deployments versioniert.

Die .devcontainer Datei dient dem Entwickeln mittels VS-Code innerhalb eines Containers, ist aber optional.

source/app-executor-docker.ts wird nicht länger benötigt.

Konfiguration Beispiel

// units.json
{
  "units":[
    "a", "b", "c"
  ]
}
// production.json
{
  "HostConfiguration": {
    "a": {
      "host": "localhost",
      "port": 1234
    },
    "b": {
      "host": "localhost",
      "port": 1235
    },
    "c": {
      "host": "localhost",
      "port": 1236
    }
  },
  "DeploymentConfiguration": {
    "a": ["barrier"],
    "b": ["DSB"],
    "c": ["user"]
  }
}

Die Umgebungsvariablen für jeden Host folgen dem Schema UNITNAME_HOST und UNITNAME_PORT

Kommunikationsart

Prinzipiell fand lediglich eine Umstrukturierung des Codes von @mtt derart statt, dass der "Kommunikations-Technologie" spezifische Code in den Ordner "communication" rausgeschnitten wurde und mit Hilfe kleiner Anpassungen dieser auch auf e.g. MQTT erweitert wurde. Nach diesem merge ist es möglich, für jede unit (i.e. container/nodejs-Prozess, in welchem eine oder mehrere SM-Components laufen) eine Kommunikationsmöglichkeit zu annotieren. Dies geschieht durch Setzen eines Feldes in der DeploymentConfiguraton, e.g. in development.json:

{
  "HostConfiguration": {
    "a": {
      "host": "localhost",
      "port": 1234
    },
    "b": {
      "host": "localhost",
      "port": 1235
    },
    "c": {
      "host": "localhost",
      "port": 1236
    }
  },
  "DeploymentConfiguration": {
    "a": {
      "components": ["barrier", "user"],
      "commOption": "REST"
    },
    "b": {
      "components": ["DSB"],
      "commOption": "REST"
    },
    "c": {
      "components": ["barrier","user","DSB"],
      "commOption": "REST"
    }
  }
}

Statt REST kann auch MQTT oder BLUETOOTH verwendet werden. Anmerkungen zu den Comm-Techs:

  • REST:
    Da der Default-Wert für commOption REST ist, sollte das deployment lokal und mMn auch in Kubernetes (jedoch noch nicht getestet) ohne jegliche Config-Änderung nach dem merge funktionieren.
  • MQTT:
    Hier müsste in e.g. der development.json jeweils lediglich "commOption": "MQTT" gesetzt werden. Starten der Applikation lokal wie gehabt über node ./dist/app-executor.js --to-execute [a|b|c]. Ich habe einen test unter mvp/source/communication/mqtt/mqtt.test.ts gebaut, welcher das bisherige "REST; development.json" Szenario reproduziert. @sv müsste entsprechend die kustomize-Skripte für das deployment in Kubernetes anpassen. Ob sich diese Kommunikation jetzt auch schon verteilt umsetzen lässt, muss noch getestet werden, da bisher nur der test-broker unter mqtt://test.mosquitto.org verwendet wird.
  • BLUETOOTH:
    Eine testweise Implementierung ist in bluetooth-server.ts erfolgt. Zur Konfiguration muss in development.json entsprechend eine BLEConfiguration vorhanden sein, z.B. "BLEConfiguration": { "a": { "host": "localhost", "port": 8081 } } . Jede Unit benötigt hier einen zusätzlichen, einzigartigen Port für die Kommunikation mit dem Bluetooth-Server. Port 8080 ist reserviert für den Server selbst! Außerdem muss "commOption": "BLUETOOTH" gesetzt sein. Bevor eine Unit auf einem Gerät gestartet wird, muss nun der BLE-Server ausgeführt werden via npm run startBLEServer. Danach erfolgt der Start einer Unit wie gewohnt per npm run startExecutor --to-execute [a|b|c].

Run your application

To execute your application, you must call:

confState = configurationStep(config, confState);

This would execute your application once or in other words execute one single step. To run your application until you stop it, you can do:

setInterval(args => {
    confState = configurationStep(config, confState);
}, params.tickRateInMilliSec);

In our case tickRateInMilliSecis set to 500 (ms) within params.ts.

Remember that it can take some time during start up until your MQTT components are connected to the MQTT broker. Your application only starts running when all MQTT components are connected.