diff --git a/src/02-getting-started/01-setup.md b/src/02-getting-started/01-setup.md index 013c4c1..38dcc21 100644 --- a/src/02-getting-started/01-setup.md +++ b/src/02-getting-started/01-setup.md @@ -1,5 +1,5 @@ # Setting up the project -For quick start we provide a minimal working [boilerplate](https://github.com/kapot65/controls-kt-boilerplate) project with controls-kt. We are going to use it as a base for our tutorial. Follow the next preparation steps: +For quick start we provide a minimal working [boilerplate](https://github.com/kapot65/controls-kt-boilerplate) project with controls-kt. We are going to use it as a base for our tutorial. Clone it with the following command: ```shell git clone https://github.com/kapot65/controls-kt-boilerplate.git ``` @@ -26,50 +26,49 @@ controls-kt-boilerplate/ │   └── DemoDevice.kt └── Server.kt ``` -This is a common kotlin mutliplatform project with enabled `jvm` and `js` targets and some controls-kt dependencies: +This is a common kotlin mutliplatform project with enabled `jvm` and `js` targets and some controls-kt dependencies. Boilerplate code contains a simple demo device and server with embed device manager. Let's take a look at the project structure: - ./src/commonMain/kotlin/devices/IDemoDevice.kt - contains a demo device interface and specification of its properties and actions. - ./src/jvmMain/kotlin/devices/DemoDevice.kt - contains a demo device implementation. - ./src/jvmMain/kotlin/Server.kt file contains a magix server with embed device manager with installed demo device. - ./src/jsMain/kotlin/Main.kt - contains a simple controls-kt client example. +Before we start, let's make some refactoring: +1. change project name to `attractor` in `settings.gradle.kts` and in project folder name. +2. change js bundle name in ./src/jsMain/resources/index.html to `attractor.js` +3. Change `IDemoDevice` to `IAttractor`, and `DemoDevice` to `Attractor` (Shift-F6 in Idea). +After refactoring, the project structure will look like this: +``` +attractor/ +├── build.gradle.kts +├── gradle.properties +├── README.md +├── settings.gradle.kts +└── src + ├── commonMain + │   └── kotlin + │   └── devices + │   └── IAttractor.kt + ├── jsMain + │   ├── kotlin + │   │   └── Main.kt + │   └── resources + │   └── index.html + └── jvmMain + └── kotlin + ├── devices + │   └── Attractor.kt + └── Server.kt +``` +To test the prepared project, you can follow the steps below: +1. start Server.kt (Ctrl-Shift-F10 from Idea) +2. start client + ```shell + ./gradlew jsBrowserRun + ``` + or from Idea gradle panel +3. check browser console to see device property changes + +Server terminal should print `tick` messages every second. Browser console should print continiously updated property values. - -
🚧 Work in progress - -? WASM supported ? - -? What is the recommended way to use kotlin multiplatform project with controls-kt ? -
- -In this section we are going to work with kotlin multiplatform project. - -If you plan to use only one platfrom (jvm/native/js) you can use regular kotlin project to simplify your SCADA architecture. - - - - - - - - - - + diff --git a/src/02-getting-started/02-create-device.md b/src/02-getting-started/02-create-device.md new file mode 100644 index 0000000..0fb042c --- /dev/null +++ b/src/02-getting-started/02-create-device.md @@ -0,0 +1,165 @@ +## Device structure +Let's take a look at device implementation. You can find it in +`./src/commonMain/kotlin/devices/IAttractor.kt` and `./src/jvmMain/kotlin/devices/Attractor.kt` files. + + +Structurally, a controls-kt device consists of three entities: device interface, device implementation and device specification. +- device interface - a kotlin interface for device implementation. + ```kotlin + interface IAttractor : Device { + var timeScaleState: Double + var sinScaleState: Double + + fun sinValue(): Double + + companion object : DeviceSpec() { + // ... + } + } + ``` + It serves as a bridge between implementation and specification. +
+ Despite of "standart" approach of using interfaces it is not intended for contolling device. You will not be able to use interface fields and methods within controls-kt API. +
+ Device interface must be used to define implementation fields and methods that needed by device specification. + + - device inteface must be placed in `common` in order to be accessible from both `jvm` and `js` targets. +- device specification - an object that contains actual device controlling interface. It exists inside interface companion object. + ```kotlin + interface IAttractor : Device { + // ... + companion object : DeviceSpec() { + val timeScale by mutableProperty(MetaConverter.double, IAttractor::timeScaleState) + val sinScale by mutableProperty(MetaConverter.double, IAttractor::sinScaleState) + + val sin by doubleProperty { sinValue() } + + val resetScale by unitAction { + write(timeScale, 5000.0) + write(sinScale, 1.0) + } + } + } + ``` + Specification serves as a bridge between device interface and magix loop and used to define device properties and actions. Every property and action defined in specification will be available in magix loop. +- device implementaion - an actual device implementation. + ```kotlin + class Attractor(context: Context, meta: Meta) : DeviceBySpec(IAttractor, context, meta), IAttractor { + override var timeScaleState = 5000.0 + override var sinScaleState = 1.0 + + private fun time(): Instant = Instant.now() + + override fun sinValue(): Double = sin(time().toEpochMilli().toDouble() / timeScaleState) * sinScaleState + + companion object : DeviceSpec(), Factory { + override fun build(context: Context, meta: Meta) = Attractor(context, meta) + override suspend fun IAttractor.onOpen() { + launch { + read(IAttractor.sinScale) + read(IAttractor.timeScale) + } + doRecurring(1.seconds) { + println("tick") + read(IAttractor.sin) + } + } + } + } + ``` + This class should contain all hardware-specific code and lifecycle management. It should be placed in `jvm` or `native` target depending on the platform. + +Now let's implement our Attractor device. We will change the initial code given by boilerplate. We start with device interface. + +## Device interface +Interface must contain all fields and methods we want to use in device specification. For our attractor +``` +dx/dt = y - ax^2 +dy/dt = -x - y +``` +we will need: +- `a` - a mutable parameter of the attractor +- `tick() -> Coords(x, y)` - a method that will calculate next step of the attractor +- `reset()` - a method that will reset the attractor coordinates + +For convenience we will define Coords as a data class: +```kotlin +@Serializable +data class Coords(val x: Double, val y: Double) +``` +for using `@Serializable` annotation we need to add serialization plugin to our `build.gradle.kts` file +```kotlin +plugins { + kotlin("multiplatform") version "1.9.22" + kotlin("plugin.serialization") version "1.9.22" // <- add this +} +``` +Now we can define our device interface: +```kotlin +interface IAttractor : Device { + var a: Double + fun tick(): Coords + fun reset() + companion object : DeviceSpec() { + // ... + } +} +``` +## Device specification +Now we can define our device specification. We will need to define `a` as a mutable property, coords as read-only property binded to `tick` and `reset` as unit action. +```kotlin +interface IAttractor : Device { + // ... + companion object : DeviceSpec() { + val a by mutableProperty(MetaConverter.double, IAttractor::a) + @OptIn(DFExperimental::class) + val coords by property(MetaConverter.serializable()) { tick() } + val reset by unitAction { reset() } + } +} +``` +Together with interface full code looks like this: +```kotlin +interface IAttractor : Device { + var a: Double + fun tick(): Coords + fun reset() + companion object : DeviceSpec() { + val a by mutableProperty(MetaConverter.double, IAttractor::a) + @OptIn(DFExperimental::class) + val coords by property(MetaConverter.serializable()) { tick() } + val reset by unitAction { reset() } + } +} +``` + +## Device implementation +Device implementation looks like this: +```kotlin +class Attractor(context: Context, meta: Meta) : DeviceBySpec(IAttractor, context, meta), IAttractor { + private var coords = Coords(0.1, 0.1) + override var a = 2.5 + override fun tick(): Coords { + val dx = coords.y - a * Math.pow(coords.x, 2.0) + val dy = -coords.x - coords.y + coords = Coords(coords.x + dx, coords.y + dy) + return coords + } + override fun reset() { + coords = Coords(0.1, 0.1) + } + companion object : DeviceSpec(), Factory { + override fun build(context: Context, meta: Meta) = Attractor(context, meta) + override suspend fun IAttractor.onOpen() { + doRecurring(1.seconds) { + println(read(IAttractor.coords)) + } + } + } +} +``` +1. First we add `coords` - an internal state of our attractor used for other device logic. +2. Then we override `a` and `tick` and `reset` methods defined in interface. `a` bridges to `a` property in specification, `tick` implements numerical solution for the next step of the attractor and `reset` resets the attractor coordinates. +3. Finally, in companion object we define lifecycle methods. `build` is a factory method that creates an instance of our device. `onOpen` is a lifecycle method that is called when device is opened. inside `onOpen` we define a recurring task that reads `coords` every second. + + diff --git a/src/02-getting-started/02-define-device-spec.md b/src/02-getting-started/02-define-device-spec.md deleted file mode 100644 index 5ed46ac..0000000 --- a/src/02-getting-started/02-define-device-spec.md +++ /dev/null @@ -1,5 +0,0 @@ -# Define the device specification - - -## Device interface - diff --git a/src/02-getting-started/03-attach-magix.md b/src/02-getting-started/03-attach-magix.md new file mode 100644 index 0000000..97538a2 --- /dev/null +++ b/src/02-getting-started/03-attach-magix.md @@ -0,0 +1,36 @@ +# Attach to Magix +Now that we have created a device, its time to attach it to Magix loop. Actually, `Server.kt` from boilerplate already all we need, so let's just take a look at it: + +```kotlin +suspend fun main(): Unit = coroutineScope { + // intialize Device Manager + val context = Context("clientContext") { + plugin(DeviceManager) + } + val manager = context.request(DeviceManager) + val device = Attractor.build(context, Meta.EMPTY) + manager.install("demo", device) + device.onOpen() + + // start Magix Loop + startMagixServer(buffer = 20) + + // attach Device Manager to Magix Loop + run { + val magixEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") + manager.launchMagixService(magixEndpoint) + } +} +``` +- First block of code initializes DeviceManager with Attractor device. To do this, we need to create a context and request DeviceManager plugin from it. Then we create an instance of Attractor device and install it to DeviceManager. +
+ currently we must call `onOpen` method manually. This will be fixed in future releases. +
+- Then we start Magix loop with `startMagixServer` function. By default, it will start Magix loop on RSocket with WebSockets support on [default](https://git.sciprog.center/kscience/controls-kt/src/commit/5b655a9354de5ddb4be25ee9e6be876f14f10b87/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixEndpoint.kt#L33) port. + - We set buffer size to 20 to get pretty traces on the client side. + + - You can tune magix loop [plugins](../04-advanced/magix-plugins.md), but it is not necessary for now. + - Magix loop is just a server, it can be initialized in separate binary. We embed it in the same binary for convenience. +- In the last block of code, we attach DeviceManager to Magix loop. We are connecting to loop via RSocket with WebSockets support (`rSocketWithWebSockets`) like we do it in any other Magix client. + + \ No newline at end of file diff --git a/src/02-getting-started/04-create-client.md b/src/02-getting-started/04-create-client.md new file mode 100644 index 0000000..43c1192 --- /dev/null +++ b/src/02-getting-started/04-create-client.md @@ -0,0 +1,186 @@ +# Create client +We have an attractor device running on the server. Now we need to create a client to visualize the attractor. + +## Preparation +In this page we focus on communication with magix. So we are going to use basic html graphics with plotly as a graph library. For complex ui we recommend to use [compose-multiplatform](https://www.jetbrains.com/lp/compose-multiplatform/) or [visionforge](https://git.sciprog.center/kscience/visionforge). +
+Currently visionforge is actively developed and not ready for production use. Documentation is not ready yet. +
+ +Let's put our ui into `index.html`: +```html + + + + Title + + + +
+ + +
+ +
+ + + +``` +It has a static plot widget to display attractor, js function to plot data, and some input fields to control `a` property and reset the attractor. + +We need some glue code to interact with ui from kotlin. +To use `plot(x, y)` we define it as external function: +```kotlin +external fun plot(x: Array, y: Array) +``` +To connect to button events we will use `addEventListener`: +```kotlin +document.getElementById("reset")!!.addEventListener("click", { + // ... +}) +``` + +Finally we clear original `main` function leaving only endpoint initialization and add plot state variables: +```kotlin +suspend fun main(): Unit = coroutineScope { + // connect to Magix + val sendEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") + val buffer = mutableListOf() + fun MutableList.pushRing(ele: Coords) { + if (buffer.size > 10) this.removeFirst() + this += ele + } +} +``` + +## Connect to device +controls-kt uses two different approaches to control devices from client side: by single propety flows or by whole device. We are going to use both of them. +
+ Currently both approaches are not ready. There is some functionality you can use only by one of them. That is the reason we need to use both of them for now. In future releases we are going to make them equal. +
+ +### Approach 1: Connect by controlsPropertyFlow +This approach is used to connect to single property of the device. It is designed to be used in cases when you need only little number of device properties on client side. We are going to use it for live update of device `coords` and `a` property. +
+Currently only read/write property is supported. No actions. +
+ + +For our needs we will use [MagixEndpoint.controlsPropertyFlow](https://git.sciprog.center/kscience/controls-kt/src/commit/2946f23a4bffd2bfd6dda6df1f618df46c4fc3d6/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt#L136) method. It creates a typed flow for desired device property. We will use it to connect to `coords` and `a` properties of attractor. +For `coords` flow we will use tranformations below: +```kotlin +launch { + sendEndpoint.controlsPropertyFlow( + "controls-kt", Name.of("demo"), IAttractor.coords).collect { coord -> + buffer.pushRing(coord) + plot(buffer.map { it.x } .toTypedArray(), buffer.map { it.y } .toTypedArray()) + } +} +``` +Each update of `coords` property pushed to handmade ring buffer. Then data from buffer is converted to plotly format and plotted. + +To connect `a` to ui we use next transformation: +```kotlin +launch { + sendEndpoint.controlsPropertyFlow("controls-kt", Name.of("demo"), IAttractor.a).collect { + document.getElementById("a_current")!!.textContent = "curr: $it" + } +} +``` +Each `a` update is simply overwrites current corresponding div text value. + +We start both flows inside `launch` scope to prevent them from blocking main coroutine. + + +### Approach 2: Connect by device +This approach is used to connect to whole device. It is designed to be used in cases when heavily use of device properties and actions is needed. We are going to write `a` changes and execute `reset` action. + +First we need to create [DeviceClient](https://git.sciprog.center/kscience/controls-kt/src/commit/2946f23a4bffd2bfd6dda6df1f618df46c4fc3d6/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt#L26) instance: +```kotlin +val device = run { + val context = Context("clientContext") {} + val device = sendEndpoint.remoteDevice( + context, + "controls-kt", + "controls-kt", + Name.of("demo") + ) + device +} +``` +To do it we need to create `Context` instance and use it with [MagixEndpoint.remoteDevice](https://git.sciprog.center/kscience/controls-kt/src/commit/2946f23a4bffd2bfd6dda6df1f618df46c4fc3d6/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt#L115) method. + +Now we have `DeviceClient` instance which stores device address. This is a general class and it does not have access to actual `Attractor` class methods. Same as with `controlsPropertyFlow` we must access to device properties and actions through device specification. + +Together with glue code it will look like: +```kotlin +document.getElementById("reset")!!.addEventListener("click", { + buffer.clear() + launch { device.execute(IAttractor.reset) } +}) +``` +for reset action (we clear buffer and execute `reset` action) and +```kotlin +document.getElementById("a_update")!!.addEventListener("click", { + launch { + val newValue = document.getElementById("a_value").unsafeCast().value.toDouble() + device.write(IAttractor.a, newValue) + } +}) +``` +for write `a` property (we read value from input field and write it to `a` property). + +## Conclusion +Full client code will look like: +```kotlin +external fun plot(x: Array, y: Array) +suspend fun main(): Unit = coroutineScope { + val sendEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") + val buffer = mutableListOf() + fun MutableList.pushRing(ele: Coords) { + if (buffer.size > 10) this.removeFirst() + this += ele + } + val device = run { + val context = Context("clientContext") + val device = sendEndpoint.remoteDevice( + context, "controls-kt", "controls-kt", + Name.of("demo") + ) + device + } + document.getElementById("reset")!!.addEventListener("click", { + buffer.clear() + launch { device.execute(IAttractor.reset) } + }) + document.getElementById("a_update")!!.addEventListener("click", { + launch { + val newValue = document.getElementById("a_value").unsafeCast().value.toDouble() + device.write(IAttractor.a, newValue) + } + }) + launch { sendEndpoint.controlsPropertyFlow( + "controls-kt", Name.of("demo"), IAttractor.coords).collect { coord -> + buffer.pushRing(coord) + plot(buffer.map { it.x } .toTypedArray(), buffer.map { it.y } .toTypedArray()) + } } + launch { sendEndpoint.controlsPropertyFlow("controls-kt", Name.of("demo"), IAttractor.a).collect { + document.getElementById("a_current")!!.textContent = "curr: $it" + } } +} +``` +To run it you can use `jsBrowserRun` gradle task. It will start a server and open a browser window with your client. +To update client code you can use `jsBrowserDevelopmentWebpack` task while `jsBrowserRun` is running or just rerun `jsBrowserRun` task. +`Server.kt` must be running to provide data for client. +In browser you should see: +![web-ui](./images/web-ui.png) +You can play with ui to check how it works. + + + \ No newline at end of file diff --git a/src/02-getting-started/README.md b/src/02-getting-started/README.md index 1c29fd1..d5f29fc 100644 --- a/src/02-getting-started/README.md +++ b/src/02-getting-started/README.md @@ -9,6 +9,7 @@ During this chapter we are going to create a simple device that simulates a simp dx/dt = y - ax^2 dy/dt = -x - y ``` + The device will have the following properties: - (x, y) - coordinates as current step - a - parameter of the attractor diff --git a/src/02-getting-started/images/web-ui.png b/src/02-getting-started/images/web-ui.png new file mode 100644 index 0000000..c43da99 Binary files /dev/null and b/src/02-getting-started/images/web-ui.png differ diff --git a/src/04-advanced/magix-plugins.md b/src/04-advanced/magix-plugins.md new file mode 100644 index 0000000..9389b5d --- /dev/null +++ b/src/04-advanced/magix-plugins.md @@ -0,0 +1,4 @@ +# Magix plugins +
+🚧 Work in progress +
\ No newline at end of file diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 7a53a33..62f48f3 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -3,11 +3,14 @@ - [Introduction](./01-introduction/README.md) - [Getting started](./02-getting-started/README.md) - [Setting up the project](./02-getting-started/01-setup.md) - - [Define device spec](./02-getting-started/02-define-device-spec.md) + - [Create device](./02-getting-started/02-create-device.md) + - [Attach device to Magix loop](./02-getting-started/03-attach-magix.md) + - [Create client](./02-getting-started/04-create-client.md) - [Reference Guide](./03-reference/README.md) - [Device Properties](./03-reference/properties.md) - [Device Actions](./03-reference/actions.md) - [Advanced usage](./04-advanced/README.md) + - [Magix plugins](./04-advanced/magix-plugins.md) - [Use latest dev build](./04-advanced/dev-build.md) - [Work with c](./04-advanced/work-with-c.md) - [Interoperability with Tango](./04-advanced/tango-interop.md)