finish getting-started draft

This commit is contained in:
chernov 2024-03-06 11:33:40 +03:00
parent caf209981a
commit a86f74a5bf
9 changed files with 436 additions and 47 deletions

View File

@ -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.
<div class="warning">🚧 Work in progress
? WASM supported ?
? What is the recommended way to use kotlin multiplatform project with controls-kt ?
</div>
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.
<!-- Вариант 1: переделка kotlin/JVM проекта в kotlin multiplatform
сделать
- ? как подключить Compose
-->
<!-- Вариант 2: создание проекта с помощью Kotlin Multiplatform Wizard
## Initialize kotlin project from Idea
First you need to create project base with [Kotlin Multiplatform Wizard](https://kmp.jetbrains.com/)
<div class="warning">avoid dashes in project name</div>
- check `Server` and `Web` checkboxes
- optionally check `Desktop` if you plan to use desktop graphics.
Generated project will have structure like: -->
<!-- выбран 1 вариант [обсуждение](https://mm.sciprog.center/controls/channels/controls-kt)
жду новой версии controls-kt для бойлерплейта
-->
<!-- добавить секцию для людей, которые имеют опыт с kotlin/multiplatform и собираются создать проект вручную
какие модули нужны и для чего
какие реализации сейчас есть
-->
<!-- add conclusion -->

View File

@ -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<IAttractor>() {
// ...
}
}
```
It serves as a bridge between implementation and specification.
<div class="warning">
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.
</div>
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<IAttractor>() {
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<Attractor>(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<IAttractor>(), Factory<Attractor> {
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<IAttractor>() {
// ...
}
}
```
## 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<IAttractor>() {
val a by mutableProperty(MetaConverter.double, IAttractor::a)
@OptIn(DFExperimental::class)
val coords by property(MetaConverter.serializable<Coords>()) { 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<IAttractor>() {
val a by mutableProperty(MetaConverter.double, IAttractor::a)
@OptIn(DFExperimental::class)
val coords by property(MetaConverter.serializable<Coords>()) { tick() }
val reset by unitAction { reset() }
}
}
```
## Device implementation
Device implementation looks like this:
```kotlin
class Attractor(context: Context, meta: Meta) : DeviceBySpec<Attractor>(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<IAttractor>(), Factory<Attractor> {
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.
<!-- add conclusion -->

View File

@ -1,5 +0,0 @@
# Define the device specification
## Device interface

View File

@ -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.
<div class="warning">
currently we must call `onOpen` method manually. This will be fixed in future releases.
</div>
- 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.
<!-- add conclusion -->

View File

@ -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).
<div class="warning">
Currently visionforge is actively developed and not ready for production use. Documentation is not ready yet.
</div>
Let's put our ui into `index.html`:
```html
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdn.plot.ly/plotly-2.29.1.min.js" charset="utf-8"></script>
</head>
<body>
<div id="plot"></div>
<script>
function plot(x, y) {
Plotly.newPlot('plot', [{
x, y, mode: 'lines'
}], { title: 'Attractor' });
}
</script>
<button id="reset">Reset</button>
<br>
<input type="number" step="0.01" value="2.5" id="a_value">
<button id="a_update">update</button><div id="a_current"></div>
<script src="attractor.js"></script>
</body>
</html>
```
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<Double>, y: Array<Double>)
```
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<Coords>()
fun MutableList<Coords>.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.
<div class="warning">
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.
</div>
### 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.
<div class="warning">
Currently only read/write property is supported. No actions.
</div>
<!-- пояснить про доступ по спеке -->
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<HTMLInputElement>().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<Double>, y: Array<Double>)
suspend fun main(): Unit = coroutineScope {
val sendEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
val buffer = mutableListOf<Coords>()
fun MutableList<Coords>.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<HTMLInputElement>().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.
<!-- add conclusion -->

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -0,0 +1,4 @@
# Magix plugins
<div class="warning">
🚧 Work in progress
</div>

View File

@ -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)