Compare commits

..

11 Commits

Author SHA1 Message Date
682af406f5 Correct discarding for invalid messages and small fixes 2024-03-19 19:14:51 +07:00
0557622dc3 Correct rotors order 2024-03-19 19:13:28 +07:00
59eb3c7440 Translation error fix 2024-03-13 21:47:16 +07:00
d2f8d0ff06 Console screen implemented 2024-03-12 22:44:33 +07:00
3e1c5edc19 Litle improvements 2024-03-09 09:36:06 +07:00
027116e18e PID contrllers settings page was added 2024-03-08 23:07:18 +07:00
c8abfd94c3 Rotors test page was added 2024-03-08 23:05:38 +07:00
0763c2e1df Message delimeter simplified 2024-03-01 22:36:43 +07:00
3ac39136c6 Data transfer service rewritten 2024-03-01 22:11:06 +07:00
5e0f2f1bb7 Joystick & Pid Configuration
Flexible PID configuration and joysticks were added
2024-02-28 23:41:30 +07:00
3517414ec1 Data tranfering improvement 2024-02-09 19:28:49 +07:00
52 changed files with 3391 additions and 128 deletions

10
.idea/deploymentTargetDropDown.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State />
</entry>
</value>
</component>
</project>

5
.idea/gradle.xml generated
View File

@@ -4,16 +4,15 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

2
.idea/kotlinc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.6.10" />
<option name="version" value="1.8.10" />
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

View File

@@ -1,8 +1,6 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp").version("1.6.10-1.0.4")
id("org.jlleitschuh.gradle.ktlint").version("12.0.3")
}
android {
@@ -65,6 +63,7 @@ dependencies {
implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
implementation("androidx.navigation:navigation-compose:2.6.0")
implementation("com.squareup.moshi:moshi-kotlin:1.14.0")
implementation("com.github.manalkaff:JetStick:1.2")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")

View File

@@ -40,12 +40,12 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Testblue"
android:theme="@style/Theme.Main"
tools:targetApi="31">
<activity
android:name="com.helible.pilot.MainActivity"
android:exported="true"
android:theme="@style/Theme.Testblue">
android:theme="@style/Theme.Main">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View File

@@ -0,0 +1,70 @@
import com.curiouscreature.kotlin.math.*
data class Camera(
inline var position: Float3 = Float3(),
inline var target: Float3 = Float3()
)
class Mesh(val name: String, val triangles: Array<Mat3>) {
var position = Float3()
var rotation = Float3()
}
val cube = Mesh(
"Cube", arrayOf(
// SOUTH
Mat3(Float3(-1.0f, -1.0f, -1.0f), Float3(-1.0f, 1.0f, -1.0f), Float3(1.0f, 1.0f, -1.0f)),
Mat3(Float3(-1.0f, -1.0f, -1.0f), Float3(1.0f, 1.0f, -1.0f), Float3(1.0f, -1.0f, -1.0f)),
// EAST
Mat3(Float3(1.0f, -1.0f, -1.0f), Float3(1.0f, 1.0f, -1.0f), Float3(1.0f, 1.0f, 1.0f)),
Mat3(Float3(1.0f, -1.0f, -1.0f), Float3(1.0f, 1.0f, 1.0f), Float3(1.0f, -1.0f, 1.0f)),
// NORTH
Mat3(Float3(1.0f, -1.0f, 1.0f), Float3(1.0f, 1.0f, 1.0f), Float3(-1.0f, 1.0f, 1.0f)),
Mat3(Float3(1.0f, -1.0f, 1.0f), Float3(-1.0f, 1.0f, 1.0f), Float3(-1.0f, -1.0f, 1.0f)),
// WEST
Mat3(Float3(-1.0f, -1.0f, 1.0f), Float3(-1.0f, 1.0f, 1.0f), Float3(-1.0f, 1.0f, -1.0f)),
Mat3(Float3(-1.0f, -1.0f, 1.0f), Float3(-1.0f, 1.0f, -1.0f), Float3(-1.0f, -1.0f, -1.0f)),
// TOP
Mat3(Float3(-1.0f, 1.0f, -1.0f), Float3(-1.0f, 1.0f, 1.0f), Float3(1.0f, 1.0f, 1.0f)),
Mat3(Float3(-1.0f, 1.0f, -1.0f), Float3(1.0f, 1.0f, 1.0f), Float3(1.0f, 1.0f, -1.0f)),
// BOTTOM
Mat3(Float3(1.0f, -1.0f, 1.0f), Float3(-1.0f, -1.0f, 1.0f), Float3(-1.0f, -1.0f, -1.0f)),
Mat3(Float3(1.0f, -1.0f, 1.0f), Float3(-1.0f, -1.0f, -1.0f), Float3(1.0f, -1.0f, -1.0f)),
)
)
fun project(coord: Float3, transMat: Mat4): Float2 {
val (x, y, _, _) = transMat * (Float4(coord, 0f))
return Float2(x, y)
}
/**
* Project triangle to 2d
* this was a shot in the dark, might be a bug here. Looks ok
*/
fun Mat3.project(transMat: Mat4): List<Float2> {
val projected = transMat * Mat4(this.x, this.y, this.z)
return listOf(
Float2(projected[0][0], projected[0][1]),
Float2(projected[1][0], projected[1][1]),
Float2(projected[2][0], projected[2][1]),
)
}
fun render3d(camera: Camera, mesh: Mesh): List<List<Float2>> {
val viewMatrix = lookAt(camera.position, camera.target)
val projectionMatrix = perspective(0.78f, 1f, 0.01f, 20f)
val worldMatrix = rotation(mesh.rotation) * translation(mesh.position)
val transformMatrix = projectionMatrix * viewMatrix * worldMatrix
return mesh.triangles.map { it.project(transformMatrix) }
}

View File

@@ -0,0 +1,521 @@
/*
* Copyright (C) 2017 Romain Guy
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.curiouscreature.kotlin.math
import kotlin.math.*
enum class MatrixColumn {
X, Y, Z, W
}
data class Mat2(
var x: Float2 = Float2(x = 1.0f),
var y: Float2 = Float2(y = 1.0f)) {
constructor(m: Mat2) : this(m.x.copy(), m.y.copy())
companion object {
fun of(vararg a: Float): Mat2 {
require(a.size >= 4)
return Mat2(
Float2(a[0], a[2]),
Float2(a[1], a[3])
)
}
fun identity() = Mat2()
}
operator fun get(column: Int) = when(column) {
0 -> x
1 -> y
else -> throw IllegalArgumentException("column must be in 0..1")
}
operator fun get(column: Int, row: Int) = get(column)[row]
operator fun get(column: MatrixColumn) = when(column) {
MatrixColumn.X -> x
MatrixColumn.Y -> y
else -> throw IllegalArgumentException("column must be X or Y")
}
operator fun get(column: MatrixColumn, row: Int) = get(column)[row]
operator fun invoke(row: Int, column: Int) = get(column - 1)[row - 1]
operator fun invoke(row: Int, column: Int, v: Float) = set(column - 1, row - 1, v)
operator fun set(column: Int, v: Float2) {
this[column].xy = v
}
operator fun set(column: Int, row: Int, v: Float) {
this[column][row] = v
}
operator fun unaryMinus() = Mat2(-x, -y)
operator fun inc(): Mat2 {
x++
y++
return this
}
operator fun dec(): Mat2 {
x--
y--
return this
}
operator fun plus(v: Float) = Mat2(x + v, y + v)
operator fun minus(v: Float) = Mat2(x - v, y - v)
operator fun times(v: Float) = Mat2(x * v, y * v)
operator fun div(v: Float) = Mat2(x / v, y / v)
operator fun times(m: Mat2): Mat2 {
val t = transpose(this)
return Mat2(
Float2(dot(t.x, m.x), dot(t.y, m.x)),
Float2(dot(t.x, m.y), dot(t.y, m.y))
)
}
operator fun times(v: Float2): Float2 {
val t = transpose(this)
return Float2(dot(t.x, v), dot(t.y, v))
}
fun toFloatArray() = floatArrayOf(
x.x, y.x,
x.y, y.y
)
override fun toString(): String {
return """
|${x.x} ${y.x}|
|${x.y} ${y.y}|
""".trimIndent()
}
}
data class Mat3(
var x: Float3 = Float3(x = 1.0f),
var y: Float3 = Float3(y = 1.0f),
var z: Float3 = Float3(z = 1.0f)) {
constructor(m: Mat3) : this(m.x.copy(), m.y.copy(), m.z.copy())
companion object {
fun of(vararg a: Float): Mat3 {
require(a.size >= 9)
return Mat3(
Float3(a[0], a[3], a[6]),
Float3(a[1], a[4], a[7]),
Float3(a[2], a[5], a[8])
)
}
fun identity() = Mat3()
}
operator fun get(column: Int) = when(column) {
0 -> x
1 -> y
2 -> z
else -> throw IllegalArgumentException("column must be in 0..2")
}
operator fun get(column: Int, row: Int) = get(column)[row]
operator fun get(column: MatrixColumn) = when(column) {
MatrixColumn.X -> x
MatrixColumn.Y -> y
MatrixColumn.Z -> z
else -> throw IllegalArgumentException("column must be X, Y or Z")
}
operator fun get(column: MatrixColumn, row: Int) = get(column)[row]
operator fun invoke(row: Int, column: Int) = get(column - 1)[row - 1]
operator fun invoke(row: Int, column: Int, v: Float) = set(column - 1, row - 1, v)
operator fun set(column: Int, v: Float3) {
this[column].xyz = v
}
operator fun set(column: Int, row: Int, v: Float) {
this[column][row] = v
}
operator fun unaryMinus() = Mat3(-x, -y, -z)
operator fun inc(): Mat3 {
x++
y++
z++
return this
}
operator fun dec(): Mat3 {
x--
y--
z--
return this
}
operator fun plus(v: Float) = Mat3(x + v, y + v, z + v)
operator fun minus(v: Float) = Mat3(x - v, y - v, z - v)
operator fun times(v: Float) = Mat3(x * v, y * v, z * v)
operator fun div(v: Float) = Mat3(x / v, y / v, z / v)
operator fun times(m: Mat3): Mat3 {
val t = transpose(this)
return Mat3(
Float3(dot(t.x, m.x), dot(t.y, m.x), dot(t.z, m.x)),
Float3(dot(t.x, m.y), dot(t.y, m.y), dot(t.z, m.y)),
Float3(dot(t.x, m.z), dot(t.y, m.z), dot(t.z, m.z))
)
}
operator fun times(v: Float3): Float3 {
val t = transpose(this)
return Float3(dot(t.x, v), dot(t.y, v), dot(t.z, v))
}
fun toFloatArray() = floatArrayOf(
x.x, y.x, z.x,
x.y, y.y, z.y,
x.z, y.z, z.z
)
override fun toString(): String {
return """
|${x.x} ${y.x} ${z.x}|
|${x.y} ${y.y} ${z.y}|
|${x.z} ${y.z} ${z.z}|
""".trimIndent()
}
}
data class Mat4(
var x: Float4 = Float4(x = 1.0f),
var y: Float4 = Float4(y = 1.0f),
var z: Float4 = Float4(z = 1.0f),
var w: Float4 = Float4(w = 1.0f)) {
constructor(right: Float3, up: Float3, forward: Float3, position: Float3 = Float3()) :
this(Float4(right), Float4(up), Float4(forward), Float4(position, 1.0f))
constructor(m: Mat4) : this(m.x.copy(), m.y.copy(), m.z.copy(), m.w.copy())
companion object {
fun of(vararg a: Float): Mat4 {
require(a.size >= 16)
return Mat4(
Float4(a[0], a[4], a[8], a[12]),
Float4(a[1], a[5], a[9], a[13]),
Float4(a[2], a[6], a[10], a[14]),
Float4(a[3], a[7], a[11], a[15])
)
}
fun identity() = Mat4()
}
inline var right: Float3
get() = x.xyz
set(value) {
x.xyz = value
}
inline var up: Float3
get() = y.xyz
set(value) {
y.xyz = value
}
inline var forward: Float3
get() = z.xyz
set(value) {
z.xyz = value
}
inline var position: Float3
get() = w.xyz
set(value) {
w.xyz = value
}
inline val scale: Float3
get() = Float3(length(x.xyz), length(y.xyz), length(z.xyz))
inline val translation: Float3
get() = w.xyz
val rotation: Float3
get() {
val x = normalize(right)
val y = normalize(up)
val z = normalize(forward)
return when {
z.y <= -1.0f -> Float3(degrees(-HALF_PI), 0.0f, degrees(atan2( x.z, y.z)))
z.y >= 1.0f -> Float3(degrees( HALF_PI), 0.0f, degrees(atan2(-x.z, -y.z)))
else -> Float3(
degrees(-asin(z.y)), degrees(-atan2(z.x, z.z)), degrees(atan2( x.y, y.y)))
}
}
inline val upperLeft: Mat3
get() = Mat3(x.xyz, y.xyz, z.xyz)
operator fun get(column: Int) = when(column) {
0 -> x
1 -> y
2 -> z
3 -> w
else -> throw IllegalArgumentException("column must be in 0..3")
}
operator fun get(column: Int, row: Int) = get(column)[row]
operator fun get(column: MatrixColumn) = when(column) {
MatrixColumn.X -> x
MatrixColumn.Y -> y
MatrixColumn.Z -> z
MatrixColumn.W -> w
}
operator fun get(column: MatrixColumn, row: Int) = get(column)[row]
operator fun invoke(row: Int, column: Int) = get(column - 1)[row - 1]
operator fun invoke(row: Int, column: Int, v: Float) = set(column - 1, row - 1, v)
operator fun set(column: Int, v: Float4) {
this[column].xyzw = v
}
operator fun set(column: Int, row: Int, v: Float) {
this[column][row] = v
}
operator fun unaryMinus() = Mat4(-x, -y, -z, -w)
operator fun inc(): Mat4 {
x++
y++
z++
w++
return this
}
operator fun dec(): Mat4 {
x--
y--
z--
w--
return this
}
operator fun plus(v: Float) = Mat4(x + v, y + v, z + v, w + v)
operator fun minus(v: Float) = Mat4(x - v, y - v, z - v, w - v)
operator fun times(v: Float) = Mat4(x * v, y * v, z * v, w * v)
operator fun div(v: Float) = Mat4(x / v, y / v, z / v, w / v)
operator fun times(m: Mat4): Mat4 {
val t = transpose(this)
return Mat4(
Float4(dot(t.x, m.x), dot(t.y, m.x), dot(t.z, m.x), dot(t.w, m.x)),
Float4(dot(t.x, m.y), dot(t.y, m.y), dot(t.z, m.y), dot(t.w, m.y)),
Float4(dot(t.x, m.z), dot(t.y, m.z), dot(t.z, m.z), dot(t.w, m.z)),
Float4(dot(t.x, m.w), dot(t.y, m.w), dot(t.z, m.w), dot(t.w, m.w))
)
}
operator fun times(v: Float4): Float4 {
val t = transpose(this)
return Float4(dot(t.x, v), dot(t.y, v), dot(t.z, v), dot(t.w, v))
}
fun toFloatArray() = floatArrayOf(
x.x, y.x, z.x, w.x,
x.y, y.y, z.y, w.y,
x.z, y.z, z.z, w.z,
x.w, y.w, z.w, w.w
)
override fun toString(): String {
return """
|${x.x} ${y.x} ${z.x} ${w.x}|
|${x.y} ${y.y} ${z.y} ${w.y}|
|${x.z} ${y.z} ${z.z} ${w.z}|
|${x.w} ${y.w} ${z.w} ${w.w}|
""".trimIndent()
}
}
fun transpose(m: Mat2) = Mat2(
Float2(m.x.x, m.y.x),
Float2(m.x.y, m.y.y)
)
fun transpose(m: Mat3) = Mat3(
Float3(m.x.x, m.y.x, m.z.x),
Float3(m.x.y, m.y.y, m.z.y),
Float3(m.x.z, m.y.z, m.z.z)
)
fun inverse(m: Mat3): Mat3 {
val a = m.x.x
val b = m.x.y
val c = m.x.z
val d = m.y.x
val e = m.y.y
val f = m.y.z
val g = m.z.x
val h = m.z.y
val i = m.z.z
val A = e * i - f * h
val B = f * g - d * i
val C = d * h - e * g
val det = a * A + b * B + c * C
return Mat3.of(
A / det, B / det, C / det,
(c * h - b * i) / det, (a * i - c * g) / det, (b * g - a * h) / det,
(b * f - c * e) / det, (c * d - a * f) / det, (a * e - b * d) / det
)
}
fun transpose(m: Mat4) = Mat4(
Float4(m.x.x, m.y.x, m.z.x, m.w.x),
Float4(m.x.y, m.y.y, m.z.y, m.w.y),
Float4(m.x.z, m.y.z, m.z.z, m.w.z),
Float4(m.x.w, m.y.w, m.z.w, m.w.w)
)
fun inverse(m: Mat4): Mat4 {
val result = Mat4()
var pair0 = m.z.z * m.w.w
var pair1 = m.w.z * m.z.w
var pair2 = m.y.z * m.w.w
var pair3 = m.w.z * m.y.w
var pair4 = m.y.z * m.z.w
var pair5 = m.z.z * m.y.w
var pair6 = m.x.z * m.w.w
var pair7 = m.w.z * m.x.w
var pair8 = m.x.z * m.z.w
var pair9 = m.z.z * m.x.w
var pair10 = m.x.z * m.y.w
var pair11 = m.y.z * m.x.w
result.x.x = pair0 * m.y.y + pair3 * m.z.y + pair4 * m.w.y
result.x.x -= pair1 * m.y.y + pair2 * m.z.y + pair5 * m.w.y
result.x.y = pair1 * m.x.y + pair6 * m.z.y + pair9 * m.w.y
result.x.y -= pair0 * m.x.y + pair7 * m.z.y + pair8 * m.w.y
result.x.z = pair2 * m.x.y + pair7 * m.y.y + pair10 * m.w.y
result.x.z -= pair3 * m.x.y + pair6 * m.y.y + pair11 * m.w.y
result.x.w = pair5 * m.x.y + pair8 * m.y.y + pair11 * m.z.y
result.x.w -= pair4 * m.x.y + pair9 * m.y.y + pair10 * m.z.y
result.y.x = pair1 * m.y.x + pair2 * m.z.x + pair5 * m.w.x
result.y.x -= pair0 * m.y.x + pair3 * m.z.x + pair4 * m.w.x
result.y.y = pair0 * m.x.x + pair7 * m.z.x + pair8 * m.w.x
result.y.y -= pair1 * m.x.x + pair6 * m.z.x + pair9 * m.w.x
result.y.z = pair3 * m.x.x + pair6 * m.y.x + pair11 * m.w.x
result.y.z -= pair2 * m.x.x + pair7 * m.y.x + pair10 * m.w.x
result.y.w = pair4 * m.x.x + pair9 * m.y.x + pair10 * m.z.x
result.y.w -= pair5 * m.x.x + pair8 * m.y.x + pair11 * m.z.x
pair0 = m.z.x * m.w.y
pair1 = m.w.x * m.z.y
pair2 = m.y.x * m.w.y
pair3 = m.w.x * m.y.y
pair4 = m.y.x * m.z.y
pair5 = m.z.x * m.y.y
pair6 = m.x.x * m.w.y
pair7 = m.w.x * m.x.y
pair8 = m.x.x * m.z.y
pair9 = m.z.x * m.x.y
pair10 = m.x.x * m.y.y
pair11 = m.y.x * m.x.y
result.z.x = pair0 * m.y.w + pair3 * m.z.w + pair4 * m.w.w
result.z.x -= pair1 * m.y.w + pair2 * m.z.w + pair5 * m.w.w
result.z.y = pair1 * m.x.w + pair6 * m.z.w + pair9 * m.w.w
result.z.y -= pair0 * m.x.w + pair7 * m.z.w + pair8 * m.w.w
result.z.z = pair2 * m.x.w + pair7 * m.y.w + pair10 * m.w.w
result.z.z -= pair3 * m.x.w + pair6 * m.y.w + pair11 * m.w.w
result.z.w = pair5 * m.x.w + pair8 * m.y.w + pair11 * m.z.w
result.z.w -= pair4 * m.x.w + pair9 * m.y.w + pair10 * m.z.w
result.w.x = pair2 * m.z.z + pair5 * m.w.z + pair1 * m.y.z
result.w.x -= pair4 * m.w.z + pair0 * m.y.z + pair3 * m.z.z
result.w.y = pair8 * m.w.z + pair0 * m.x.z + pair7 * m.z.z
result.w.y -= pair6 * m.z.z + pair9 * m.w.z + pair1 * m.x.z
result.w.z = pair6 * m.y.z + pair11 * m.w.z + pair3 * m.x.z
result.w.z -= pair10 * m.w.z + pair2 * m.x.z + pair7 * m.y.z
result.w.w = pair10 * m.z.z + pair4 * m.x.z + pair9 * m.y.z
result.w.w -= pair8 * m.y.z + pair11 * m.z.z + pair5 * m.x.z
val determinant = m.x.x * result.x.x + m.y.x * result.x.y + m.z.x * result.x.z + m.w.x * result.x.w
return result / determinant
}
fun scale(s: Float3) = Mat4(Float4(x = s.x), Float4(y = s.y), Float4(z = s.z))
fun scale(m: Mat4) = scale(m.scale)
fun translation(t: Float3) = Mat4(w = Float4(t, 1.0f))
fun translation(m: Mat4) = translation(m.translation)
fun rotation(m: Mat4) = Mat4(normalize(m.right), normalize(m.up), normalize(m.forward))
fun rotation(d: Float3): Mat4 {
val r = transform(d, ::radians)
val c = transform(r, { x -> cos(x) })
val s = transform(r, { x -> sin(x) })
return Mat4.of(
c.y * c.z, -c.x * s.z + s.x * s.y * c.z, s.x * s.z + c.x * s.y * c.z, 0.0f,
c.y * s.z, c.x * c.z + s.x * s.y * s.z, -s.x * c.z + c.x * s.y * s.z, 0.0f,
-s.y , s.x * c.y , c.x * c.y , 0.0f,
0.0f , 0.0f , 0.0f , 1.0f
)
}
fun rotation(axis: Float3, angle: Float): Mat4 {
val x = axis.x
val y = axis.y
val z = axis.z
val r = radians(angle)
val c = cos(r)
val s = sin(r)
val d = 1.0f - c
return Mat4.of(
x * x * d + c , x * y * d - z * s, x * z * d + y * s, 0.0f,
y * x * d + z * s, y * y * d + c , y * z * d - x * s, 0.0f,
z * x * d - y * s, z * y * d + x * s, z * z * d + c , 0.0f,
0.0f , 0.0f , 0.0f , 1.0f
)
}
fun normal(m: Mat4) = scale(1.0f / Float3(length2(m.right), length2(m.up), length2(m.forward))) * m
fun lookAt(eye: Float3, target: Float3, up: Float3 = Float3(z = 1.0f)): Mat4 {
return lookTowards(eye, target - eye, up)
}
fun lookTowards(eye: Float3, forward: Float3, up: Float3 = Float3(z = 1.0f)): Mat4 {
val f = normalize(forward)
val r = normalize(f x up)
val u = normalize(r x f)
return Mat4(Float4(r), Float4(u), Float4(f), Float4(eye, 1.0f))
}
fun perspective(fov: Float, ratio: Float, near: Float, far: Float): Mat4 {
val t = 1.0f / tan(radians(fov) * 0.5f)
val a = (far + near) / (far - near)
val b = (2.0f * far * near) / (far - near)
val c = t / ratio
return Mat4(Float4(x = c), Float4(y = t), Float4(z = a, w = 1.0f), Float4(z = -b))
}
fun ortho(l: Float, r: Float, b: Float, t: Float, n: Float, f: Float) = Mat4(
Float4(x = 2.0f / (r - 1.0f)),
Float4(y = 2.0f / (t - b)),
Float4(z = -2.0f / (f - n)),
Float4(-(r + l) / (r - l), -(t + b) / (t - b), -(f + n) / (f - n), 1.0f)
)

View File

@@ -0,0 +1,21 @@
/*
* Copyright (C) 2017 Romain Guy
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.curiouscreature.kotlin.math
data class Ray(var origin: Float3 = Float3(), var direction: Float3)
fun pointAt(r: Ray, t: Float) = r.origin + r.direction * t

View File

@@ -0,0 +1,43 @@
/*
* Copyright (C) 2017 Romain Guy
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("NOTHING_TO_INLINE")
package com.curiouscreature.kotlin.math
const val PI = 3.1415926536f
const val HALF_PI = PI * 0.5f
const val TWO_PI = PI * 2.0f
const val FOUR_PI = PI * 4.0f
const val INV_PI = 1.0f / PI
const val INV_TWO_PI = INV_PI * 0.5f
const val INV_FOUR_PI = INV_PI * 0.25f
inline fun clamp(x: Float, min: Float, max: Float)= if (x < min) min else (if (x > max) max else x)
inline fun saturate(x: Float) = clamp(x, 0.0f, 1.0f)
inline fun mix(a: Float, b: Float, x: Float) = a * (1.0f - x) + b * x
inline fun degrees(v: Float) = v * (180.0f * INV_PI)
inline fun radians(v: Float) = v * (PI / 180.0f)
inline fun fract(v: Float) = v % 1
inline fun sqr(v: Float) = v * v
//inline fun pow(x: Float, y: Float) = StrictMath.pow(x.toDouble(), y.toDouble()).toFloat()

View File

@@ -1,23 +0,0 @@
package com.helible.pilot
// Todo: add checksum
// Todo: add arguments names
data class KMessage(
val r1: UShort,
val r2: UShort,
val r3: UShort,
val emergStop: Boolean,
val alarm: Boolean,
)
fun KMessage.toByteArray(): ByteArray {
return "$$r1;$r2;$r3;$emergStop;$alarm\r\n".encodeToByteArray()
}
fun String.toKMessage(): KMessage {
// TODO: implement
return KMessage(
0u, 0u, 0u, emergStop = false, alarm = false
)
}

View File

@@ -6,10 +6,8 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -17,9 +15,15 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.helible.pilot.components.CalibrationPage
import com.helible.pilot.components.NotImplementedPage
import com.helible.pilot.components.RotorsTestPage
import com.helible.pilot.components.console.ConsolePage
import com.helible.pilot.components.deviceScreen.DeviceControlScreen
import com.helible.pilot.components.deviceScreen.defaultDeviceActionsList
import com.helible.pilot.components.PidSettingsPage
import com.helible.pilot.components.scannerScreen.ScannerScreen
import com.helible.pilot.dataclasses.DeviceStatus
import com.helible.pilot.permissions.PermissionsLauncher
import com.helible.pilot.permissions.PermissionsRequest
import com.helible.pilot.permissions.RequestHardwareFeatures
@@ -33,11 +37,7 @@ import com.helible.pilot.viewmodels.SavedPreferencesImpl
class MainActivity : ComponentActivity() {
// TODO: device screen logic
// TODO: constrain text size
// TODO: add Bluetooth telemetry...
// TODO: move text strings to resources
// TODO: review permissions logic
private val preferences by lazy {
SavedPreferencesImpl(getSharedPreferences(packageName, MODE_PRIVATE))
@@ -46,6 +46,7 @@ class MainActivity : ComponentActivity() {
PreferencesViewModel(preferences)
}
@ExperimentalStdlibApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
@@ -63,19 +64,12 @@ class MainActivity : ComponentActivity() {
val bluetoothState by bluetoothViewModel.state.collectAsState()
val selectedDevice by bluetoothViewModel.selectedDevice.collectAsState()
val rotorsDuty by bluetoothViewModel.rotorsDuty.collectAsState()
LaunchedEffect(key1 = null) {
permissionLauncher.launch()
}
LaunchedEffect(key1 = bluetoothState) {
if (bluetoothState.isConnected) {
Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_SHORT)
.show()
}
}
val navController = rememberNavController()
LaunchedEffect(key1 = bluetoothState.errorMessage) {
@@ -150,13 +144,37 @@ class MainActivity : ComponentActivity() {
if (preferencesViewModel.preferences != null) BackHandler {}
}
composable("console/{title}")
{ backStackEntry ->
NotImplementedPage(
title = backStackEntry.arguments?.getString("title") ?: "null",
navigateBack = { navController.popBackStack() }
{ _ ->
ConsolePage(
startTakeoff = { bluetoothViewModel.startTakeoff() },
startOnboarding = { bluetoothViewModel.startOnboarding() },
stop = { bluetoothViewModel.stopRotors() },
changeStick1Position = {
_: Int, heightVelocity: Int ->
bluetoothViewModel.changeHeightStickPosition(heightVelocity)
},
changeStick2Position = {
x: Int, y: Int ->
bluetoothViewModel.changePitchStickPosition(y)
bluetoothViewModel.changeYawStickPosition(x)
},
bluetoothUiState = bluetoothState,
reconnect = {
val preferences = preferences.getPreferences()
if(preferences != null) {
bluetoothViewModel.connectToDevice(
preferences.deviceAddress
)
Button(onClick = { bluetoothViewModel.sendHelloWorld() }) {
Text("Click me!")
} else {
navController.navigate("scanner")
}
},
navigateBack = {
navController.popBackStack()
}
)
BackHandler {
navController.popBackStack()
}
}
composable("codeblocks/{title}")
@@ -168,24 +186,49 @@ class MainActivity : ComponentActivity() {
}
composable("imu_calibration/{title}")
{ backStackEntry ->
NotImplementedPage(
CalibrationPage(
deviceStatus = bluetoothState.deviceState?.status,
startCalibration = { bluetoothViewModel.startImuCalibration() },
title = backStackEntry.arguments?.getString("title") ?: "null",
navigateBack = { navController.popBackStack() }
)
}
composable("motor_test/{title}")
{ backStackEntry ->
NotImplementedPage(
RotorsTestPage(
title = backStackEntry.arguments?.getString("title") ?: "null",
navigateBack = { navController.popBackStack() }
rotorsDuty = rotorsDuty,
setRotorsDuty = { bluetoothViewModel.setRotorsDuty(it) },
startTelemetrySending = { bluetoothViewModel.startRotorsConfigurationTelemetry() },
stopRotors = { bluetoothViewModel.stopRotors() },
navigateBack = {
navController.popBackStack()
bluetoothViewModel.stopRotorsConfigurationTelemetry()
bluetoothViewModel.stopRotors()
}
)
BackHandler {
navController.popBackStack()
bluetoothViewModel.stopRotorsConfigurationTelemetry()
bluetoothViewModel.stopRotors()
}
}
composable("pid_settings/{title}")
{ backStackEntry ->
NotImplementedPage(
PidSettingsPage(
title = backStackEntry.arguments?.getString("title") ?: "null",
navigateBack = { navController.popBackStack() }
navigateBack = {
navController.popBackStack()
bluetoothViewModel.clearPidSettings()
},
requestPidSettings = { bluetoothViewModel.requestPidSettings() },
setPidSettings = {settings -> bluetoothViewModel.applyPidSettings(settings)},
deviceState = bluetoothState.deviceState
)
BackHandler {
navController.popBackStack()
bluetoothViewModel.clearPidSettings()
}
}
composable("reports/{title}")
{ backStackEntry ->

View File

@@ -0,0 +1,85 @@
package com.helible.pilot.components
import android.content.res.Configuration
import android.widget.Spinner
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.helible.pilot.R
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.ChangedDeviceStatus
import com.helible.pilot.dataclasses.DeviceState
import com.helible.pilot.dataclasses.DeviceStatus
@Composable
fun CalibrationPage(
deviceStatus: DeviceStatus?,
title: String,
startCalibration: () -> Unit,
navigateBack: () -> Unit
) {
BlankPage(title = title, navigateBack = navigateBack) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = LocalContext.current.getString(R.string.calibration_description),
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
)
Button(
enabled = deviceStatus != DeviceStatus.IsImuCalibration,
onClick = startCalibration,
modifier = Modifier.padding(10.dp)
) {
if(deviceStatus != DeviceStatus.IsImuCalibration) {
Icon(
painter = painterResource(id = R.drawable.tune),
contentDescription = null,
modifier = Modifier.padding(3.dp)
)
Text(
text = "Начать калибровку"
)
} else {
CircularProgressIndicator ()
Text(
text = "Калибровка...",
modifier = Modifier.padding(5.dp)
)
}
}
}
}
}
@Preview(showSystemUi = true)
@Composable
fun CalibrationPagePreview() {
Surface {
CalibrationPage(
DeviceStatus.IsImuCalibration,
title = "Калибровка гироскопа и акселерометра",
startCalibration = {},
navigateBack = {}
)
}
}

View File

@@ -1,4 +1,4 @@
package com.helible.pilot
package com.helible.pilot.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth

View File

@@ -0,0 +1,320 @@
package com.helible.pilot.components
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import com.helible.pilot.R
import com.helible.pilot.dataclasses.DeviceState
import com.helible.pilot.dataclasses.DeviceStatus
import com.helible.pilot.dataclasses.PidParams
import com.helible.pilot.dataclasses.PidSettings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PidSettingsPage(
title: String,
navigateBack: () -> Unit,
requestPidSettings: () -> Unit,
setPidSettings: (PidSettings) -> Unit,
deviceState: DeviceState?,
) {
BlankPage(title = title, navigateBack = navigateBack) {
var pValue by remember { mutableStateOf("") }
var iValue by remember { mutableStateOf("") }
var dValue by remember { mutableStateOf("") }
val dropdownMenuItems =
listOf("Контроллер высоты", "Контроллер тангажа", "Контроллер рысканья")
var selectedRegulator by remember { mutableStateOf(dropdownMenuItems[0]) }
LaunchedEffect(null) {
requestPidSettings()
}
LaunchedEffect(deviceState?.pidSettings) {
if (deviceState?.pidSettings != null) {
val pidSettings = deviceState.pidSettings
when (selectedRegulator) {
dropdownMenuItems[0] -> {
pidSettings.heightControllerParams.p.toString().also { pValue = it }
pidSettings.heightControllerParams.i.toString().also { iValue = it }
pidSettings.heightControllerParams.d.toString().also { dValue = it }
}
dropdownMenuItems[1] -> {
pidSettings.yawControllerParams.p.toString().also { pValue = it }
pidSettings.yawControllerParams.i.toString().also { iValue = it }
pidSettings.yawControllerParams.d.toString().also { dValue = it }
}
dropdownMenuItems[2] -> {
pidSettings.pitchControllerParams.p.toString().also { pValue = it }
pidSettings.pitchControllerParams.i.toString().also { iValue = it }
pidSettings.pitchControllerParams.d.toString().also { dValue = it }
}
}
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
)
{
if (deviceState?.status != DeviceStatus.Idle) {
Text(
text = "Этот раздел доступен только с подключенным заряженным бездействующим устройством.",
textAlign = TextAlign.Center
)
} else if (deviceState.pidSettings == null) {
CircularProgressIndicator(modifier = Modifier.padding(10.dp))
Text(text = "Синхронизация...")
} else {
val pidSettings = deviceState.pidSettings
Column(modifier = Modifier.padding(horizontal = 10.dp).padding(bottom = 10.dp)) {
Text(
"Рекомендации по настройке ПИД регуляторов",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.padding(vertical = 10.dp)
)
Text(
text = LocalContext.current.getString(R.string.p_pid_value_description),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = LocalContext.current.getString(R.string.i_pid_value_description),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = LocalContext.current.getString(R.string.d_pid_value_description),
style = MaterialTheme.typography.bodyMedium
)
}
OutlinedDropdownMenu(
label = "ПИД регулятор",
suggestions = dropdownMenuItems,
onChange = { selected ->
Log.i("BluetoothVM", selected)
when (dropdownMenuItems.indexOf(selected)) {
0 -> {
selectedRegulator = dropdownMenuItems[0]
pidSettings.heightControllerParams.p.toString().also { pValue = it }
pidSettings.heightControllerParams.i.toString().also { iValue = it }
pidSettings.heightControllerParams.d.toString().also { dValue = it }
}
1 -> {
selectedRegulator = dropdownMenuItems[1]
pidSettings.yawControllerParams.p.toString().also { pValue = it }
pidSettings.yawControllerParams.i.toString().also { iValue = it }
pidSettings.yawControllerParams.d.toString().also { dValue = it }
}
2 -> {
selectedRegulator = dropdownMenuItems[2]
pidSettings.pitchControllerParams.p.toString().also { pValue = it }
pidSettings.pitchControllerParams.i.toString().also { iValue = it }
pidSettings.pitchControllerParams.d.toString().also { dValue = it }
}
}
},
modifier = Modifier.padding(10.dp)
)
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(10.dp)
) {
OutlinedTextField(
value = pValue,
onValueChange = { pValue = it },
label = { Text(text = "P") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = iValue,
onValueChange = { iValue = it },
label = { Text(text = "I") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = dValue,
onValueChange = { dValue = it },
label = { Text(text = "D") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
}
val p = pValue
val i = iValue
val d = dValue
Button(
onClick = {
when (dropdownMenuItems.indexOf(selectedRegulator)) {
0 -> {
val newPidSettings = pidSettings.copy(
heightControllerParams = PidParams(
p.toFloat(),
i.toFloat(),
d.toFloat()
)
)
setPidSettings(newPidSettings)
}
1 -> {
val newPidSettings = pidSettings.copy(
yawControllerParams = PidParams(
p.toFloat(),
i.toFloat(),
d.toFloat()
)
)
setPidSettings(newPidSettings)
}
2 -> {
val newPidSettings = pidSettings.copy(
pitchControllerParams = PidParams(
p.toFloat(),
i.toFloat(),
d.toFloat()
)
)
setPidSettings(newPidSettings)
}
}
},
enabled = isValidValue(p) && isValidValue(i) && isValidValue(d)
) {
Text(text = "Применить")
}
}
}
}
}
private fun isValidValue(k: String): Boolean {
return k.toFloatOrNull() != null && k.toFloat() >= 0f && k.toFloat() <= 15f
}
@Preview(showBackground = true)
@Composable
fun PidSettingsPreview() {
PidSettingsPage(
title = "Настройки ПИД регуляторов",
navigateBack = { },
requestPidSettings = { },
setPidSettings = {},
deviceState = DeviceState(
status = DeviceStatus.Idle,
pidSettings = PidSettings(
PidParams(1f, 1f, 1f),
PidParams(1f, 1f, 1f),
PidParams(1f, 1f, 1f)
)
)
)
}
@Preview(showBackground = true)
@Composable
fun DropdownDemo() {
OutlinedDropdownMenu(label = "", suggestions = listOf("A", "B"), onChange = {})
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OutlinedDropdownMenu(
label: String,
suggestions: List<String>,
onChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
var selectedText by remember { mutableStateOf(suggestions.first()) }
var textfieldSize by remember { mutableStateOf(Size.Zero) }
val icon = if (expanded)
Icons.Filled.KeyboardArrowUp
else
Icons.Filled.KeyboardArrowDown
Column(modifier = modifier) {
OutlinedTextField(
value = selectedText,
onValueChange = {
selectedText = it
},
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates ->
// This value is used to assign to the DropDown the same width
textfieldSize = coordinates.size.toSize()
},
label = { Text(label) },
trailingIcon = {
Icon(icon, "contentDescription",
Modifier.clickable { expanded = !expanded })
}
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier
.width(with(LocalDensity.current) { textfieldSize.width.toDp() })
) {
suggestions.forEach { label ->
DropdownMenuItem(onClick = {
selectedText = label
expanded = false
onChange(selectedText)
},
text = { Text(label) }
)
}
}
}
}

View File

@@ -0,0 +1,119 @@
package com.helible.pilot.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.helible.pilot.dataclasses.RotorsDuty
import com.helible.pilot.ui.theme.TestblueTheme
@Composable
fun RotorsTestPage(
title: String,
rotorsDuty: RotorsDuty,
setRotorsDuty: (duty: RotorsDuty) -> Unit,
startTelemetrySending: () -> Unit,
stopRotors: () -> Unit,
navigateBack: () -> Unit,
) {
BlankPage(title = title, navigateBack = navigateBack) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
LaunchedEffect(null) {
startTelemetrySending()
}
Text(
text = "R1",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 10.dp)
)
Slider(
value = rotorsDuty.r1.toFloat(),
onValueChange = { setRotorsDuty(rotorsDuty.copy(r1 = it.toInt().toShort())) },
valueRange = 0f..5000f,
steps = 10
)
Text(
text = "При перемещении слайдера вправо ротор 1 должен вращаться против часовой стрелки, если смотреть сверху.",
textAlign = TextAlign.Center
)
Text(
text = "R2",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 10.dp)
)
Slider(
value = rotorsDuty.r2.toFloat(),
onValueChange = { setRotorsDuty(rotorsDuty.copy(r2 = it.toInt().toShort())) },
valueRange = 0f..5000f,
steps = 10
)
Text(
text = "При отклонении слайдера вправо от центра ротор 1 должен вращаться по часовой стрелке, а при отклонении влево - против часовой, если смотреть сверху.",
textAlign = TextAlign.Center
)
Text(
text = "R3",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 10.dp)
)
Slider(
value = rotorsDuty.r3.toFloat(),
onValueChange = { setRotorsDuty(rotorsDuty.copy(r3 = it.toInt().toShort())) },
valueRange = -5000f..5000f,
steps = 10
)
Text(
text = "При перемещении слайдера вправо ротор 1 должен вращаться по часовой стрелке, если смотреть сверху.",
textAlign = TextAlign.Center
)
FloatingActionButton(
onClick = { stopRotors() },
containerColor = Color(245, 47, 7),
modifier = Modifier.padding(top = 10.dp)
) {
Text(
text = "СТОП",
style = MaterialTheme.typography.headlineLarge,
color = Color.White,
modifier = Modifier.padding(10.dp)
)
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun RotorsTestPagePreview() {
TestblueTheme {
RotorsTestPage(
title = "Тестирование моторов",
rotorsDuty = RotorsDuty(5, 5, 5),
setRotorsDuty = { _ -> },
startTelemetrySending = {},
stopRotors = {},
navigateBack = { /*TODO*/ }
)
}
}

View File

@@ -0,0 +1,63 @@
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.curiouscreature.kotlin.math.Float3
@Composable
fun World(mesh: Mesh = cube, drawVertices: MutableState<Boolean>, camerax: MutableState<Float>) {
val camera = Camera(
position = Float3(20f, 110.1f, 110.0f),
target = Float3(0.1f, camerax.value, 1.0f)
)
Text("Camera: ${camera.target.y}")
val animatedProgress by rememberInfiniteTransition().animateFloat(
initialValue = 0.01f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 10000, easing = LinearEasing),
),
)
Canvas(Modifier.size(50.dp, 50.dp)) {
mesh.rotation = Float3(animatedProgress + 90, animatedProgress + 180, 0.01f)
camera.target.y += 0.1f
camera.target.x += 0.1f
camera.target.z -= 0.1f
val lines = render3d(camera, mesh)
lines.forEach { (one, two, three) ->
if (drawVertices.value) {
drawCircle(color = Color.Cyan, radius = 5f, center = Offset(one.x, one.y))
drawCircle(color = Color.Cyan, radius = 5f, center = Offset(two.x, two.y))
drawCircle(color = Color.Cyan, radius = 5f, center = Offset(three.x, three.y))
}
drawLine(
color = Color.Red,
start = Offset(one.x, one.y),
end = Offset(two.x, two.y)
)
drawLine(
color = Color.Red,
start = Offset(two.x, two.y),
end = Offset(three.x, three.y)
)
drawLine(
color = Color.Red,
start = Offset(three.x, three.y),
end = Offset(one.x, one.y)
)
}
}
}

View File

@@ -0,0 +1,238 @@
package com.helible.pilot.components.console
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExitToApp
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.helible.pilot.R
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.DeviceStatus
import com.manalkaff.jetstick.JoyStick
@Composable
fun ConsolePage(
startTakeoff: () -> Unit,
startOnboarding: () -> Unit,
stop: () -> Unit,
reconnect: () -> Unit,
changeStick1Position: (x: Int, y: Int) -> Unit,
changeStick2Position: (x: Int, y: Int) -> Unit,
bluetoothUiState: BluetoothUiState,
navigateBack: () -> Unit,
) {
val deviceState = bluetoothUiState.deviceState
LockScreenOrientation(orientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
LaunchedEffect(key1 = null) {
Log.i("Console", "state: ${bluetoothUiState.deviceState}, isConnected: ${bluetoothUiState.isConnected}")
}
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
if (bluetoothUiState.isConnected && deviceState != null) {
Text("Высота полёта: ${deviceState.flightHeight / 100} м; ")
Text("Рыскание: ${deviceState.yaw}°; ")
Text("Тангаж: ${deviceState.pitch}°; ")
Text("Крен: ${deviceState.roll}°; ")
}
}
Row(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
JoyStick(
size = 200.dp,
dotSize = 80.dp,
backgroundImage = R.drawable.stick_background,
dotImage = R.drawable.stick_dot,
modifier = Modifier.padding(30.dp)
) { x: Float, y: Float ->
changeStick1Position(x.toInt(), y.toInt())
Log.d("JoyStick", "$x, $y")
}
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (bluetoothUiState.isConnected && deviceState != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceState.status == DeviceStatus.IsPreparingForTakeoff) {
CircularProgressIndicator(modifier = Modifier.padding(7.dp))
}
Text(text = describeStatus(deviceState.status))
}
Text(text = "Заряд батареи: ${deviceState.batteryCharge}%")
} else {
Text(text = "Нет соединения с устройством")
}
if (bluetoothUiState.isConnecting) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.padding(5.dp))
Text(text = "Подключение...")
}
} else if (bluetoothUiState.isConnected) {
Button(
onClick = startTakeoff,
enabled = deviceState?.status == DeviceStatus.Idle
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.KeyboardArrowUp, contentDescription = null)
Text(text = "Взлёт")
}
}
Button(onClick = stop, colors = ButtonDefaults.buttonColors(Color.Red)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Warning, contentDescription = null)
Text(text = "СТОП")
}
}
Button(
onClick = startOnboarding,
enabled = deviceState?.status in listOf(
DeviceStatus.IsFlying, DeviceStatus.IsPreparingForTakeoff
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.KeyboardArrowDown, contentDescription = null)
Text(text = "Посадка")
}
}
} else {
Button(onClick = reconnect) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Refresh, contentDescription = null)
Text(text = "Попробовать ещё раз")
}
}
}
if(!bluetoothUiState.isEnabled || deviceState?.status in listOf(DeviceStatus.ChargeRequired, DeviceStatus.Idle, null)) {
Button(
onClick = navigateBack
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.ExitToApp, contentDescription = null)
Text(text = "Выход")
}
}
}
}
JoyStick(
size = 200.dp,
dotSize = 80.dp,
backgroundImage = R.drawable.stick_background,
dotImage = R.drawable.stick_dot,
) { x: Float, y: Float ->
changeStick2Position(x.toInt(), y.toInt())
Log.d("JoyStick", "$x, $y")
}
}
}
}
@Composable
fun LockScreenOrientation(orientation: Int) {
val context = LocalContext.current
DisposableEffect(orientation) {
val activity = context.findActivity() ?: return@DisposableEffect onDispose {}
val originalOrientation = activity.requestedOrientation
activity.requestedOrientation = orientation
onDispose {
// restore original orientation when view disappears
activity.requestedOrientation = originalOrientation
}
}
}
fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
fun describeStatus(deviceState: DeviceStatus): String {
return when (deviceState) {
DeviceStatus.Idle -> {
"Устройство заряжено, бездействует"
}
DeviceStatus.IsFlying -> {
"В полёте"
}
DeviceStatus.ChargeRequired -> {
"Устройство разряжено. Дальнейшие полёты невозможны"
}
DeviceStatus.IsImuCalibration -> {
"Калибровка гироскопа и акселерометра. Пожалуйста, не двигайте устройство."
}
DeviceStatus.IsPreparingForTakeoff -> {
"Подготовка ко взлёту..."
}
DeviceStatus.IsBoarding -> {
"Посадка"
}
}
}
@Preview(showBackground = true, device = "spec:parent=pixel_5,orientation=landscape")
@Composable
fun ConsolePreview() {
ConsolePage(
startTakeoff = {},
startOnboarding = {},
stop = {},
changeStick1Position = { _: Int, _: Int -> },
changeStick2Position = { _: Int, _: Int -> },
bluetoothUiState = BluetoothUiState().copy(
isConnected = false,
deviceState = null
),
reconnect = {},
navigateBack = {}
)
}

View File

@@ -19,6 +19,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
@@ -26,6 +27,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.helible.pilot.R
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.DeviceState
import com.helible.pilot.dataclasses.DeviceStatus
import com.helible.pilot.viewmodels.AppPreferences
@Composable
@@ -65,7 +68,19 @@ fun DeviceBadge(
fontWeight = FontWeight.Bold
)
DeviceConnectionStatus(bluetoothUiState)
Text(text = "Заряд батареи: 79%")
if(bluetoothUiState.isConnected) {
val deviceStatus = bluetoothUiState.deviceState?.status
if (deviceStatus != null) {
Text(text = "Заряд батареи: ${bluetoothUiState.deviceState.batteryCharge}%")
if (deviceStatus == DeviceStatus.ChargeRequired) {
Text(text = "Аккумулятор разряжен", color = Color.Red)
} else {
Text(text = deviceStatus.description())
}
} else {
Text(text = "Ожиданием рукопожатия...")
}
}
}
Box(
contentAlignment = Alignment.CenterEnd,
@@ -89,7 +104,14 @@ fun DeviceBadge(
@Composable
fun DeviceBadgePreview() {
DeviceBadge(
bluetoothUiState = BluetoothUiState(isConnected = true),
bluetoothUiState = BluetoothUiState(
isConnected = false,
isConnecting = true,
deviceState = DeviceState(
status = DeviceStatus.Idle,
batteryCharge = 50,
)
),
tryToReconnect = {},
getPreferences = { AppPreferences("Helicopter", "AA:BB:CC:FF:DD") }
)

View File

@@ -51,6 +51,8 @@ fun DeviceConnectionStatus(bluetoothState: BluetoothUiState) {
.padding(2.dp)
)
Text("Подключение...")
} else {
Text("Попытка подключения не удалась.")
}
}
}

View File

@@ -22,7 +22,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.helible.pilot.R
import com.helible.pilot.components.Title
import com.helible.pilot.components.scannerScreen.Title
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.viewmodels.AppPreferences

View File

@@ -22,7 +22,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import com.helible.pilot.components.Title
import com.helible.pilot.dataclasses.BluetoothDevice
import com.helible.pilot.dataclasses.BluetoothUiState

View File

@@ -1,4 +1,4 @@
package com.helible.pilot.components
package com.helible.pilot.components.scannerScreen
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable

View File

@@ -15,8 +15,8 @@ import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import com.helible.pilot.viewmodels.BluetoothDataTransferService
import com.helible.pilot.KMessage
import com.helible.pilot.dataclasses.BluetoothDeviceDomain
import com.helible.pilot.dataclasses.GeneralMessage
import com.helible.pilot.receivers.BluetoothAdapterStateReceiver
import com.helible.pilot.receivers.BluetoothStateReceiver
import kotlinx.coroutines.CoroutineScope
@@ -40,7 +40,7 @@ import java.util.UUID
sealed interface ConnectionResult {
object ConnectionEstablished : ConnectionResult
data class TransferSucceded(val message: String) : ConnectionResult
data class TransferSucceded(val message: GeneralMessage) : ConnectionResult
data class Error(val message: String) : ConnectionResult
}
@@ -61,6 +61,7 @@ interface BluetoothController {
fun onDestroy()
}
@ExperimentalStdlibApi
class AndroidBluetoothController(private val context: Context) : BluetoothController {
private val bluetoothManager by lazy {
@@ -228,21 +229,26 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
try {
socket.connect()
emit(ConnectionResult.ConnectionEstablished)
BluetoothDataTransferService(socket).also { it ->
BluetoothDataTransferService(socket).also {
dataTransferService = it
emitAll(
it.listenForIncomingMessages()
.map { ConnectionResult.TransferSucceded(it) }
.map { deviceState ->
ConnectionResult.TransferSucceded(deviceState)
}
)
}
} catch (e: IOException) {
socket.close()
currentClientSocket = null
Log.e("BluetoothController", e.toString())
Log.e("BluetoothController", "I/O exception: ${e.message}")
emit(ConnectionResult.Error("Connection was interrupted"))
}
}
}.onCompletion { closeConnection() }.flowOn(Dispatchers.IO)
}.onCompletion {
Log.i("BluetoothController", "Connection closed on flow completion.")
closeConnection()
}.flowOn(Dispatchers.IO)
}
override suspend fun trySendMessage(message: ByteArray): Boolean {

View File

@@ -1,9 +0,0 @@
package com.helible.pilot.controllers
data class DeviceState(
val isHandshakeWaiting: Boolean = true,
val isIMUCalibrating: Boolean = false,
val flightMode: Boolean = false,
val batteryCharge: Int?,
val flightHeight: Float?
)

View File

@@ -9,4 +9,5 @@ data class BluetoothUiState(
val errorMessage: String? = null,
val scannedBluetoothDevices: List<BluetoothDevice> = emptyList(),
val pairedBluetoothDevices: List<BluetoothDevice> = emptyList(),
val deviceState: DeviceState? = null
)

View File

@@ -0,0 +1,6 @@
package com.helible.pilot.dataclasses
data class ChangedDeviceStatus(
val status: DeviceStatus
)

View File

@@ -0,0 +1,16 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DeviceState(
val status: DeviceStatus = DeviceStatus.ChargeRequired,
@Json(name = "charge") val batteryCharge: Int = 10,
val flightHeight: Float = 0f,
@Json(name = "y") val yaw: Float = 0f,
@Json(name = "p") val pitch: Float = 0f,
@Json(name = "r") val roll: Float = 0f,
@Json(name = "zIn") val zInertial: Float = 0f,
val pidSettings: PidSettings? = null
)

View File

@@ -0,0 +1,21 @@
package com.helible.pilot.dataclasses
enum class DeviceStatus {
Idle,
IsPreparingForTakeoff,
IsFlying,
IsBoarding,
IsImuCalibration,
ChargeRequired;
fun description(): String {
return when (this) {
Idle -> "Готово к работе"
IsPreparingForTakeoff -> "Подготовка к полёту"
IsFlying -> "В полёте"
IsBoarding -> "Посадка"
IsImuCalibration -> "Калибровка..."
ChargeRequired -> "Аккумулятор разряжен"
}
}
}

View File

@@ -0,0 +1,22 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.ToJson
class DeviceStatusJsonAdapter {
@FromJson
fun fromJson(deviceStatus: String): DeviceStatus {
try {
val index: UInt = deviceStatus.toUInt()
return DeviceStatus.values()[index.toInt()]
} catch (e: IndexOutOfBoundsException) {
throw JsonDataException("Impossible conversation from String to DeviceStatus")
}
}
@ToJson
fun toJson(deviceStatus: DeviceStatus): String {
return DeviceStatus.values().indexOf(deviceStatus).toString()
}
}

View File

@@ -0,0 +1,7 @@
package com.helible.pilot.dataclasses
// This dataclass provide message content with its type without any markers
data class GeneralMessage(
val type: MessageType,
val data: String
)

View File

@@ -0,0 +1,6 @@
package com.helible.pilot.dataclasses
enum class MessageType {
UpdateMessage,
PidSettings
}

View File

@@ -1,12 +0,0 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RotorsSpeedMessage(val r1: Short, val r2: Short, val r3: Short)
@JsonClass(generateAdapter = true)
data class EmergStopMessage(val emergStop: Boolean)
@JsonClass(generateAdapter = true)
data class AlarmStateMessage(val isAlarmOn: Boolean)

View File

@@ -0,0 +1,10 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PidParams (
val p: Float,
val i: Float,
val d: Float
)

View File

@@ -0,0 +1,8 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PidSettingRequiredMessage (
val pidSettingOpened: Boolean = true
)

View File

@@ -0,0 +1,11 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PidSettings (
@Json(name = "p1") val heightControllerParams: PidParams,
@Json(name = "p2") val yawControllerParams: PidParams,
@Json(name = "p3") val pitchControllerParams: PidParams
)

View File

@@ -0,0 +1,10 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RotorsDuty(
val r1: Short,
val r2: Short,
val r3: Short
)

View File

@@ -0,0 +1,6 @@
package com.helible.pilot.dataclasses
data class StickPosition (
val x: Float,
val y: Float
)

View File

@@ -0,0 +1,10 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.Json
data class SticksPosition(
@Json(name = "hS") val heightStick: Int,
@Json(name = "yS") val yawStick: Int,
@Json(name = "pS") val pitchStick: Int
)

View File

@@ -0,0 +1,8 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class StopMessage(
val stop: Boolean = true
)

View File

@@ -0,0 +1,5 @@
package com.helible.pilot.exceptions
import java.io.IOException
class TransferFailedException : IOException("Reading incoming data failed")

View File

@@ -2,37 +2,48 @@ package com.helible.pilot.viewmodels
import android.bluetooth.BluetoothSocket
import android.util.Log
import com.helible.pilot.KMessage
import com.helible.pilot.toKMessage
import com.helible.pilot.dataclasses.GeneralMessage
import com.helible.pilot.dataclasses.MessageType
import com.helible.pilot.exceptions.TransferFailedException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.IOException
class TransferFailedException : IOException("Reading incoming data failed")
const val maxPackageSize = 512 // [bytes]
class BluetoothDataTransferService(
private val socket: BluetoothSocket,
) {
fun listenForIncomingMessages(): Flow<String> {
fun listenForIncomingMessages(): Flow<GeneralMessage> {
return flow {
if (!socket.isConnected)
return@flow
val buffer = ByteArray(128)
val buffer = BufferedInputStream(socket.inputStream, maxPackageSize)
while (true) {
val byteCount: Int = try {
socket.inputStream.read(buffer)
val message: String = try {
buffer.bufferedReader().readLine()
} catch (e: IOException) {
Log.e("BluetoothController", "Failed to receive incoming data")
throw TransferFailedException()
}
val strData: String = buffer.decodeToString(endIndex = byteCount)
emit(
strData
)
Log.i("BluetoothController", "Received: ${strData.dropLast(2)}")
try {
val messageType =
MessageType.values().elementAtOrNull(message.split(";").first().toInt())
val messageData = message.split(";").last()
if (messageType != null) {
emit(GeneralMessage(messageType, messageData))
}
} catch (e: NoSuchElementException) {
Log.e("BluetoothController", "Message type is invalid: ${e.message}")
} catch (e: NumberFormatException) {
Log.e("BluetoothController", "Message invalid, may be device buffer congested: ${e.message}")
} catch (e: Exception) {
Log.e("BluetoothController", "Unknown error: ${e.message}")
}
}
}.flowOn(Dispatchers.IO)
}
@@ -42,10 +53,11 @@ class BluetoothDataTransferService(
try {
socket.outputStream.write(bytes)
} catch (e: IOException) {
e.printStackTrace()
Log.e("BluetoothController", "Failed to write message: $e")
return@withContext false
}
true
}
}
}

View File

@@ -1,14 +1,24 @@
package com.helible.pilot.viewmodels
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.helible.pilot.controllers.BluetoothController
import com.helible.pilot.controllers.ConnectionResult
import com.helible.pilot.dataclasses.AlarmStateMessage
import com.helible.pilot.dataclasses.BluetoothDevice
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.EmergStopMessage
import com.helible.pilot.dataclasses.RotorsSpeedMessage
import com.helible.pilot.dataclasses.ChangedDeviceStatus
import com.helible.pilot.dataclasses.DeviceState
import com.helible.pilot.dataclasses.DeviceStatus
import com.helible.pilot.dataclasses.DeviceStatusJsonAdapter
import com.helible.pilot.dataclasses.MessageType
import com.helible.pilot.dataclasses.PidSettingRequiredMessage
import com.helible.pilot.dataclasses.PidSettings
import com.helible.pilot.dataclasses.RotorsDuty
import com.helible.pilot.dataclasses.SticksPosition
import com.helible.pilot.dataclasses.StopMessage
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.JsonEncodingException
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.Job
@@ -20,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
@@ -43,10 +54,31 @@ class BluetoothViewModel(
pairedBluetoothDevices = pairedDevices
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _state.value)
private val moshi: Moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val rotorsStateMessegeAdapter = moshi.adapter(RotorsSpeedMessage::class.java)
private val alarmStateMessageAdapter = moshi.adapter(AlarmStateMessage::class.java)
private val emergStopMessageAdapter = moshi.adapter(EmergStopMessage::class.java)
private val _rotorsDuty: MutableStateFlow<RotorsDuty> = MutableStateFlow(RotorsDuty(0, 0, 0))
private val _isRotorsTelemetryEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false)
private val _isConsoleTelemetryEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false)
private val _sticksPosition: MutableStateFlow<SticksPosition> = MutableStateFlow(SticksPosition(0, 0, 0))
val rotorsDuty: StateFlow<RotorsDuty>
get() = _rotorsDuty.asStateFlow()
private var deviceConnectionJob: Job? = null
private val moshi =
Moshi.Builder().add(KotlinJsonAdapterFactory()).add(DeviceStatusJsonAdapter()).build()
private val statusMessageAdapter = moshi.adapter(ChangedDeviceStatus::class.java)
private val deviceStateMessageAdapter = moshi.adapter(DeviceState::class.java)
private val pidSittingsMessageAdapter = moshi.adapter(PidSettings::class.java)
private val pidSittingsRequiredMessageAdapter =
moshi.adapter(PidSettingRequiredMessage::class.java)
private val rotorDutyMessageAdapter = moshi.adapter(RotorsDuty::class.java)
private val stopAllRotorsMessageAdapter = moshi.adapter(StopMessage::class.java)
private val consoleStateMessageAdapter = moshi.adapter(SticksPosition::class.java)
companion object {
const val messageDelimiter = "\n"
const val telemetryPauseDuractionMs: Long = 100
}
init {
bluetoothController.isConnected.onEach { isConnected ->
@@ -94,9 +126,37 @@ class BluetoothViewModel(
}
is ConnectionResult.TransferSucceded -> {
_state.update { it.copy(
try {
when (result.message.type) {
MessageType.PidSettings -> {
val newPidSettings =
pidSittingsMessageAdapter.fromJson(result.message.data)
_state.update {
it.copy(
deviceState = it.deviceState?.copy(pidSettings = newPidSettings)
)
}
}
) }
MessageType.UpdateMessage -> {
val newDeviceState =
deviceStateMessageAdapter.fromJson(result.message.data)
if (newDeviceState != null) {
_state.update {
it.copy(
deviceState = newDeviceState.copy(pidSettings = it.deviceState?.pidSettings)
)
}
}
}
}
} catch (e: JsonDataException) {
Log.e("BluetoothVM", "Failed to parse message: ${result.message.data}")
} catch (e: JsonEncodingException) {
Log.e("BluetoothVM", "Failed to decode message: ${result.message.data}")
} catch (e: Exception) {
Log.e("BluetoothVM", "Unknown error on message: ${result.message.data}")
}
}
is ConnectionResult.Error -> {
@@ -111,19 +171,24 @@ class BluetoothViewModel(
}
}
.catch { throwable ->
Log.e(
"BluetoothVM",
"Error occured while data transfer: ${throwable.localizedMessage}"
)
bluetoothController.closeConnection()
_state.update { it.copy(
_state.update {
it.copy(
isConnected = false,
isConnecting = false
) }
isConnecting = false,
deviceState = null
)
}
}
.launchIn(viewModelScope)
}
private var deviceConnectionJob: Job? = null
fun connectToDevice(device: String) {
if (_state.value.isConnected and _state.value.isConnecting) {
if (_state.value.isConnected or _state.value.isConnecting) {
return
}
_state.update { it.copy(isConnecting = true) }
@@ -135,10 +200,13 @@ class BluetoothViewModel(
fun disconnectFromDevice() {
deviceConnectionJob?.cancel()
bluetoothController.closeConnection()
_isConsoleTelemetryEnabled.update { false }
_isRotorsTelemetryEnabled.update { false }
_state.update {
it.copy(
isConnecting = false,
isConnected = false
isConnected = false,
deviceState = null
)
}
}
@@ -162,12 +230,196 @@ class BluetoothViewModel(
super.onCleared()
}
fun sendHelloWorld() {
fun startImuCalibration() {
viewModelScope.launch {
bluetoothController.trySendMessage(
"{\"p1\": {\"p\": 1.5, \"i\": 1.5, \"d\": 1.5}}\n\r".toByteArray()
//"{\"p1\": [1.5, 1.5, 1.5]}\n\r".toByteArray()
val message = statusMessageAdapter.toJson(
ChangedDeviceStatus(DeviceStatus.IsImuCalibration)
) + messageDelimiter
val isSuccess = bluetoothController.trySendMessage(
message.toByteArray()
)
if (!isSuccess) {
Log.e("BluetoothVM", "Failed to start IMU calibration: $message")
} else {
_state.update {
it.copy(
deviceState = it.deviceState?.copy(status = DeviceStatus.IsImuCalibration)
)
}
}
}
}
fun requestPidSettings() {
viewModelScope.launch {
val message =
pidSittingsRequiredMessageAdapter.toJson(PidSettingRequiredMessage(true)) + messageDelimiter
Log.i("BluetoothVM", "Requested PID settings: $message")
val isSuccess = bluetoothController.trySendMessage(
message.toByteArray()
)
if (!isSuccess) {
Log.e("BluetoothVM", "Failed to request PID settings: $message")
}
}
}
fun applyPidSettings(pidSettings: PidSettings) {
viewModelScope.launch {
val message = pidSittingsMessageAdapter.toJson(pidSettings) + messageDelimiter
val isSuccess = bluetoothController.trySendMessage(message.toByteArray())
if (!isSuccess) {
Log.e("BluetoothVM", "Failed to request PID settings: $message")
_state.update {
it.copy(errorMessage = "Не удалось обновить значения PID")
}
} else {
_state.update {
it.copy(deviceState = it.deviceState?.copy(pidSettings = pidSettings))
}
}
}
}
fun clearPidSettings() {
Log.i("BluetoothVM", "PidSettings cleared")
_state.update {
it.copy(deviceState = it.deviceState?.copy(pidSettings = null))
}
Log.i("BluetoothVM", "PidSettings: ${_state.value.deviceState?.pidSettings}")
}
private fun sendRotorsDuty() {
viewModelScope.launch {
val message = rotorDutyMessageAdapter.toJson(
_rotorsDuty.value
) + messageDelimiter
val isSuccess = bluetoothController.trySendMessage(
message.toByteArray()
)
if (!isSuccess) {
Log.e("BluetoothVM", "Failed to send rotors telemetry: $message")
}
}
}
fun startRotorsConfigurationTelemetry() {
Log.i("BluetoothVM", "Start send rotors configuration telemetry...")
if(_isRotorsTelemetryEnabled.value) return
_isRotorsTelemetryEnabled.update { true }
flow {
while(_isRotorsTelemetryEnabled.value) {
emit(Unit)
delay(telemetryPauseDuractionMs)
}
}.onEach{
sendRotorsDuty()
Log.d("BluetoothVM", "Sended rotors telemetry")
}.launchIn(viewModelScope)
}
fun stopRotorsConfigurationTelemetry() {
Log.i("BluetoothVM", "Stop send rotors configuration periodically...")
_isRotorsTelemetryEnabled.update { false }
}
fun setRotorsDuty(newRotorsDuty: RotorsDuty) {
_rotorsDuty.update { newRotorsDuty }
}
fun stopRotors() {
viewModelScope.launch {
val message = stopAllRotorsMessageAdapter.toJson(StopMessage()) + messageDelimiter
val isSuccess = bluetoothController.trySendMessage(message.toByteArray())
if (!isSuccess) {
Log.e("BluetoothVM", "Failed to stop all rotors: $message")
_state.update {
it.copy(errorMessage = "Не удалось остановить моторы!")
}
} else {
_rotorsDuty.update { RotorsDuty(0, 0, 0) }
_isConsoleTelemetryEnabled.update { false }
}
}
}
fun startTakeoff() {
viewModelScope.launch {
val message = statusMessageAdapter.toJson(ChangedDeviceStatus(DeviceStatus.IsFlying)) + messageDelimiter
val isSuccess = bluetoothController.trySendMessage(message.toByteArray())
if(!isSuccess) {
Log.e("BluetoothVM", "Failed to start takeoff: $message")
_state.update {
it.copy(errorMessage = "Не удалось начать полёт!")
}
} else {
_state.update { it.copy(deviceState = it.deviceState?.copy(status = DeviceStatus.IsFlying)) }
startConsoleTelemetrySending()
}
}
}
fun startOnboarding() {
viewModelScope.launch {
val message = statusMessageAdapter.toJson(ChangedDeviceStatus(DeviceStatus.IsBoarding)) + messageDelimiter
val isSuccess = bluetoothController.trySendMessage(message.toByteArray())
if(!isSuccess) {
Log.e("BluetoothVM", "Failed to start onboarding: $message")
} else {
_state.update { it.copy(deviceState = it.deviceState?.copy(status = DeviceStatus.IsBoarding)) }
stopConsoleTelemetry()
}
}
}
fun changeHeightStickPosition(newHeightStickPosition: Int) {
_sticksPosition.update {
it.copy(heightStick = newHeightStickPosition)
}
}
fun changeYawStickPosition(newYawStickPosition: Int) {
_sticksPosition.update {
it.copy(yawStick = newYawStickPosition)
}
}
fun changePitchStickPosition(newPitchStickPosition: Int) {
_sticksPosition.update {
it.copy(pitchStick = newPitchStickPosition)
}
}
private fun sendConsoleState() {
viewModelScope.launch {
val message = consoleStateMessageAdapter.toJson(
_sticksPosition.value
) + messageDelimiter
val isSuccess = bluetoothController.trySendMessage(
message.toByteArray()
)
Log.i("BluetoothVM", "Sended telemetry message: $message")
if (!isSuccess) {
Log.e("BluetoothVM", "Failed to send console telemetry: $message")
}
}
}
private fun startConsoleTelemetrySending() {
if(_isConsoleTelemetryEnabled.value) {
return
}
_isConsoleTelemetryEnabled.update { true }
flow {
while (_isConsoleTelemetryEnabled.value) {
emit(Unit)
delay(telemetryPauseDuractionMs)
}
}.onEach {
sendConsoleState()
}.launchIn(viewModelScope)
}
private fun stopConsoleTelemetry() {
_isConsoleTelemetryEnabled.update { false }
}
}

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.helible.pilot.controllers.AndroidBluetoothController
@ExperimentalStdlibApi
@Suppress("UNCHECKED_CAST")
class BluetoothViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="210dp"
android:height="210dp"
android:viewportWidth="210"
android:viewportHeight="210">
<path
android:pathData="M0.5,105.5a105,105 0,1 0,210 0a105,105 0,1 0,-210 0z"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="95dp"
android:height="95dp"
android:viewportWidth="95"
android:viewportHeight="95">
<path
android:pathData="M0,47.5a47.5,47.5 0,1 0,95 0a47.5,47.5 0,1 0,-95 0z"
android:fillColor="#f83a14"
android:strokeColor="#00000000"/>
</vector>

View File

@@ -1,3 +1,7 @@
<resources>
<string name="app_name">Digital Pilot</string>
<string name="calibration_description">Расположите устройство на ровной горизонтальной поверхности, чтобы сани вертолета полностью лежали на ней. Нажмите кнопку калибровки ниже и ждите её окончания, не создавая тряски.</string>
<string name="p_pid_value_description">Сначала подберите значение коэффицента P, которое балансирует между слишком низкой и слишком высокой чувствительностью.</string>
<string name="i_pid_value_description">Затем подберите значение коэффицента I, которое уберёт нежелательный дрейф, но не повлияет на отзывчивость.</string>
<string name="d_pid_value_description">После установите значение коэффицента D таким образом, чтобы обеспечить более стабильное и плавное управление.</string>
</resources>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Testblue" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.Main" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.1.0" apply false
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.8.10" apply false
id("org.jetbrains.kotlin.jvm") version "1.8.10" apply false
}

View File

@@ -1,6 +1,6 @@
#Sun Aug 13 15:00:54 KRAT 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -3,6 +3,7 @@ pluginManagement {
google()
mavenCentral()
gradlePluginPortal()
maven(url = "https://jitpack.io")
}
}
dependencyResolutionManagement {
@@ -10,6 +11,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io")
}
}