Initial commit: M20 Gamepad control app

山猫 M20 四足机器人手柄控制 Android App,使用 UDP + JSON 协议控制机器人运动。

- 协议层:16 字节小端 Header + JSON ASDU 编解码
- 网络层:UDP 客户端,心跳 1Hz,断线检测 3s
- 连接状态机:空闲/连接中/已连接/断线 + 运动状态机:空闲/站立/RL 控制
- 虚拟摇杆:Compose 原生摇杆库,angle/power -> X/Y/Yaw 映射
- 视频播放:Media3 ExoPlayer RTSP,支持 AUTO/FORCE_HW/FORCE_SW_H264/FORCE_SW_H265
- 设置界面:机器人 IP/端口、RTSP 地址、编码器选择,DataStore 持久化
- 状态面板:异常列表、运控状态、设备温度、电池信息
- 控制按钮:模式/步态/照明/充电/休眠

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-07-17 19:24:39 +08:00
commit 04afb3331f
77 changed files with 5659 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+63
View File
@@ -0,0 +1,63 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
`maven-publish`
}
android {
namespace = "com.erz.joysticklibrary"
compileSdk = 36
defaultConfig {
minSdk = 21
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
publishing {
singleVariant("release") {
withSourcesJar()
}
}
}
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
}
publishing {
publications {
register<MavenPublication>("release") {
groupId = "com.github.erz05"
artifactId = "JoyStick"
version = "2.0.0"
afterEvaluate {
from(components["release"])
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /Users/edgarramirez/sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
@@ -0,0 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true">
</application>
</manifest>
@@ -0,0 +1,279 @@
package com.erz.joysticklibrary
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.sqrt
@Deprecated(
message = "Use the Compose-based Joystick composable instead.",
replaceWith = ReplaceWith("Joystick", "com.erz.joysticklibrary.Joystick")
)
class JoyStick @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs), GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener {
companion object {
const val DIRECTION_CENTER = -1
const val DIRECTION_LEFT = 0
const val DIRECTION_LEFT_UP = 1
const val DIRECTION_UP = 2
const val DIRECTION_UP_RIGHT = 3
const val DIRECTION_RIGHT = 4
const val DIRECTION_RIGHT_DOWN = 5
const val DIRECTION_DOWN = 6
const val DIRECTION_DOWN_LEFT = 7
const val TYPE_8_AXIS = 11
const val TYPE_4_AXIS = 22
const val TYPE_2_AXIS_LEFT_RIGHT = 33
const val TYPE_2_AXIS_UP_DOWN = 44
private fun calculateDirection(degrees: Double): Int {
return when {
(degrees >= 0 && degrees < 22.5) || (degrees < 0 && degrees > -22.5) -> DIRECTION_LEFT
degrees >= 22.5 && degrees < 67.5 -> DIRECTION_LEFT_UP
degrees >= 67.5 && degrees < 112.5 -> DIRECTION_UP
degrees >= 112.5 && degrees < 157.5 -> DIRECTION_UP_RIGHT
(degrees >= 157.5 && degrees <= 180) || (degrees >= -180 && degrees < -157.5) -> DIRECTION_RIGHT
degrees >= -157.5 && degrees < -112.5 -> DIRECTION_RIGHT_DOWN
degrees >= -112.5 && degrees < -67.5 -> DIRECTION_DOWN
degrees >= -67.5 && degrees < -22.5 -> DIRECTION_DOWN_LEFT
else -> DIRECTION_CENTER
}
}
}
interface JoyStickListener {
fun onMove(joyStick: JoyStick?, angle: Double, power: Double, direction: Int)
fun onTap()
fun onDoubleTap()
}
private var listener: JoyStickListener? = null
private val paint: Paint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
isFilterBitmap = true
}
private val temp: RectF = RectF()
private val gestureDetector: GestureDetector
private var direction = DIRECTION_CENTER
private var type = TYPE_8_AXIS
private var centerX = 0f
private var centerY = 0f
private var posX = 0f
private var posY = 0f
private var radius = 0f
private var buttonRadius = 0f
private var power = 0.0
private var angle = 0.0
// Background Color
private var padColor: Int = Color.WHITE
// Stick Color
private var buttonColor: Int = Color.RED
// Keeps joystick in last position
private var stayPut = false
// Button Size percentage of the minimum(width, height)
private var percentage = 25
// Background Bitmap
private var padBGBitmap: Bitmap? = null
// Button Bitmap
private var buttonBitmap: Bitmap? = null
init {
gestureDetector = GestureDetector(context, this).apply {
setIsLongpressEnabled(false)
setOnDoubleTapListener(this@JoyStick)
}
if (attrs != null) {
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.JoyStick)
padColor = typedArray.getColor(R.styleable.JoyStick_padColor, Color.WHITE)
buttonColor = typedArray.getColor(R.styleable.JoyStick_buttonColor, Color.RED)
stayPut = typedArray.getBoolean(R.styleable.JoyStick_stayPut, false)
percentage = typedArray.getInt(R.styleable.JoyStick_percentage, 25)
if (percentage > 50) percentage = 50
if (percentage < 25) percentage = 25
val padResId = typedArray.getResourceId(R.styleable.JoyStick_backgroundDrawable, -1)
val buttonResId = typedArray.getResourceId(R.styleable.JoyStick_buttonDrawable, -1)
if (padResId > 0) {
padBGBitmap = BitmapFactory.decodeResource(resources, padResId)
}
if (buttonResId > 0) {
buttonBitmap = BitmapFactory.decodeResource(resources, buttonResId)
}
typedArray.recycle()
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec).toFloat()
val height = MeasureSpec.getSize(heightMeasureSpec).toFloat()
centerX = width / 2
centerY = height / 2
val minDim = minOf(width, height)
posX = centerX
posY = centerY
buttonRadius = minDim / 2f * (percentage / 100f)
radius = minDim / 2f * ((100f - percentage) / 100f)
}
override fun onDraw(canvas: Canvas) {
val pad = padBGBitmap
if (pad == null) {
paint.color = padColor
canvas.drawCircle(centerX, centerY, radius, paint)
} else {
temp.set(centerX - radius, centerY - radius, centerX + radius, centerY + radius)
canvas.drawBitmap(pad, null, temp, paint)
}
val button = buttonBitmap
if (button == null) {
paint.color = buttonColor
canvas.drawCircle(posX, posY, buttonRadius, paint)
} else {
temp.set(posX - buttonRadius, posY - buttonRadius, posX + buttonRadius, posY + buttonRadius)
canvas.drawBitmap(button, null, temp, paint)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
gestureDetector.onTouchEvent(event)
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
posX = event.x
posY = event.y
when (type) {
TYPE_2_AXIS_LEFT_RIGHT -> {
posY = centerY
}
TYPE_2_AXIS_UP_DOWN -> {
posX = centerX
}
TYPE_4_AXIS -> {
if (abs(posX - centerX) > abs(posY - centerY)) {
posY = centerY
} else {
posX = centerX
}
}
}
val absVal = sqrt(((posX - centerX) * (posX - centerX) + (posY - centerY) * (posY - centerY)).toDouble()).toFloat()
if (absVal > radius) {
posX = (posX - centerX) * radius / absVal + centerX
posY = (posY - centerY) * radius / absVal + centerY
}
angle = atan2((centerY - posY).toDouble(), (centerX - posX).toDouble())
power = 100 * sqrt(((posX - centerX) * (posX - centerX) + (posY - centerY) * (posY - centerY)).toDouble()) / radius
direction = calculateDirection(Math.toDegrees(angle))
invalidate()
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (!stayPut) {
posX = centerX
posY = centerY
direction = DIRECTION_CENTER
angle = 0.0
power = 0.0
invalidate()
}
}
}
listener?.onMove(this, angle, power, direction)
return true
}
override fun onDown(motionEvent: MotionEvent): Boolean = true
override fun onShowPress(motionEvent: MotionEvent) {}
override fun onSingleTapUp(motionEvent: MotionEvent): Boolean = false
override fun onScroll(motionEvent: MotionEvent?, motionEvent1: MotionEvent, v: Float, v1: Float): Boolean = false
override fun onLongPress(motionEvent: MotionEvent) {}
override fun onFling(motionEvent: MotionEvent?, motionEvent1: MotionEvent, v: Float, v1: Float): Boolean = false
override fun onSingleTapConfirmed(motionEvent: MotionEvent): Boolean {
listener?.onTap()
return false
}
override fun onDoubleTap(motionEvent: MotionEvent): Boolean {
listener?.onDoubleTap()
return false
}
override fun onDoubleTapEvent(motionEvent: MotionEvent): Boolean = false
fun setListener(listener: JoyStickListener?) {
this.listener = listener
}
fun getPower(): Double = power
fun getAngle(): Double = angle
fun getAngleDegrees(): Double = Math.toDegrees(angle)
fun getDirection(): Int = direction
fun getType(): Int = type
fun setPadColor(padColor: Int) {
this.padColor = padColor
}
fun setButtonColor(buttonColor: Int) {
this.buttonColor = buttonColor
}
fun setButtonRadiusScale(scale: Int) {
percentage = scale
if (percentage > 50) percentage = 50
if (percentage < 25) percentage = 25
}
fun enableStayPut(enable: Boolean) {
this.stayPut = enable
}
fun setPadBackground(resId: Int) {
this.padBGBitmap = BitmapFactory.decodeResource(resources, resId)
}
fun setPadBackground(bitmap: Bitmap?) {
this.padBGBitmap = bitmap
}
fun setButtonDrawable(resId: Int) {
this.buttonBitmap = BitmapFactory.decodeResource(resources, resId)
}
fun setButtonDrawable(bitmap: Bitmap?) {
this.buttonBitmap = bitmap
}
fun setType(type: Int) {
this.type = type
}
}
@@ -0,0 +1,289 @@
package com.erz.joysticklibrary
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.min
import kotlin.math.sqrt
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastFirstOrNull
enum class JoystickDirection(val value: Int) {
CENTER(-1),
LEFT(0),
LEFT_UP(1),
UP(2),
UP_RIGHT(3),
RIGHT(4),
RIGHT_DOWN(5),
DOWN(6),
DOWN_LEFT(7);
companion object {
private val valuesByValue = entries.associateBy { it.value }
fun fromValue(value: Int): JoystickDirection = valuesByValue[value] ?: CENTER
}
}
enum class JoystickType(val value: Int) {
EIGHT_AXIS(11),
FOUR_AXIS(22),
TWO_AXIS_HORIZONTAL(33),
TWO_AXIS_VERTICAL(44)
}
/**
* A highly customizable, performant, and responsive Joystick composable for Jetpack Compose.
*
* It supports various axis restrictions, customizable shapes/colors, custom drawables/painters,
* and built-in gesture callbacks (move, tap, and double tap).
*
* ### How to Use
*
* ```kotlin
* Joystick(
* modifier = Modifier
* .size(150.dp)
* .background(Color.Gray)
* .clip(CircleShape),
* type = JoystickType.EIGHT_AXIS,
* stayPut = false,
* onMove = { angle, power, direction ->
* // Handle movement
* // angle (radians), power (0% - 100%), direction (enum)
* },
* onTap = {
* // Handle tap
* },
* onDoubleTap = {
* // Handle double tap
* }
* )
* ```
*
* @param modifier The modifier to be applied to the layout. Crucial for setting the size (e.g., [Modifier.size]) of the joystick.
* @param type The movement restriction constraints ([JoystickType]). Defaults to [JoystickType.EIGHT_AXIS].
* @param stayPut If true, the thumb will stay in its last position when released. If false, it snaps back to the center.
* @param radiusScale Scale ratio of the button (thumb) radius relative to the total joystick radius. Range: `[0.25f, 0.50f]`.
* @param padColor Fallback background color of the pad if no [padPainter] is provided.
* @param buttonColor Fallback background color of the thumb button if no [buttonPainter] is provided.
* @param padPainter Custom [Painter] for drawing the background pad of the joystick.
* @param buttonPainter Custom [Painter] for drawing the joystick thumb button.
* @param onMove Callback triggered when the joystick is dragged. Receives:
* - `angle` (Double): Direction angle in radians (ranging from `-PI` to `PI`).
* - `power` (Double): Travel displacement percentage of the button from the center, ranging from `0.0` to `100.0`.
* - `direction` (JoystickDirection): Decoded directional zone (e.g. `UP`, `DOWN_LEFT`, `CENTER`).
* @param onTap Optional callback triggered when the joystick is clicked (pointer down & up without dragging).
* @param onDoubleTap Optional callback triggered when the joystick is double-clicked.
*/
@Composable
fun Joystick(
modifier: Modifier = Modifier,
type: JoystickType = JoystickType.EIGHT_AXIS,
stayPut: Boolean = false,
radiusScale: Float = 0.25f,
padColor: Color = Color.White,
buttonColor: Color = Color.Red,
padPainter: Painter? = null,
buttonPainter: Painter? = null,
onMove: (angle: Double, power: Double, direction: JoystickDirection) -> Unit = { _, _, _ -> },
onTap: (() -> Unit)? = null,
onDoubleTap: (() -> Unit)? = null
) {
val currentOnMove by rememberUpdatedState(onMove)
val currentOnTap by rememberUpdatedState(onTap)
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
val scale = remember(radiusScale) { radiusScale.coerceIn(0.25f, 0.50f) }
var thumbOffsetX by remember { mutableFloatStateOf(0f) }
var thumbOffsetY by remember { mutableFloatStateOf(0f) }
Canvas(
modifier = modifier
.fillMaxSize()
.pointerInput(stayPut, type, scale) {
val touchSlop = viewConfiguration.touchSlop
var lastTapTime = 0L
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val now = System.currentTimeMillis()
val isDoubleTap = now - lastTapTime < 300L
var dragTriggered = false
val pointerId = down.id
fun updatePosition(rawX: Float, rawY: Float) {
val currentWidth = size.width
val currentHeight = size.height
val centerX = currentWidth / 2f
val centerY = currentHeight / 2f
val minDim = min(currentWidth, currentHeight).toFloat()
val maxTravelDistance = (minDim / 2f) * (1f - scale)
if (maxTravelDistance <= 0f) return
val deltaX = rawX - centerX
val deltaY = rawY - centerY
var targetDx = deltaX
var targetDy = deltaY
when (type) {
JoystickType.TWO_AXIS_HORIZONTAL -> targetDy = 0f
JoystickType.TWO_AXIS_VERTICAL -> targetDx = 0f
JoystickType.FOUR_AXIS -> {
if (abs(deltaX) > abs(deltaY)) targetDy = 0f else targetDx = 0f
}
JoystickType.EIGHT_AXIS -> { /* No restriction */ }
}
val distance = sqrt(targetDx * targetDx + targetDy * targetDy)
val newOffsetX: Float
val newOffsetY: Float
if (distance > maxTravelDistance) {
newOffsetX = targetDx * maxTravelDistance / distance
newOffsetY = targetDy * maxTravelDistance / distance
} else {
newOffsetX = targetDx
newOffsetY = targetDy
}
// Skip callback if thumb position hasn't meaningfully changed (prevent micro-jitter)
if (abs(newOffsetX - thumbOffsetX) < 0.01f && abs(newOffsetY - thumbOffsetY) < 0.01f) return
thumbOffsetX = newOffsetX
thumbOffsetY = newOffsetY
val power = (100f * sqrt(thumbOffsetX * thumbOffsetX + thumbOffsetY * thumbOffsetY) / maxTravelDistance).toDouble()
val angle = atan2(-thumbOffsetY, -thumbOffsetX).toDouble()
val direction = calculateDirection(Math.toDegrees(angle))
currentOnMove(angle, power, direction)
}
updatePosition(down.position.x, down.position.y)
while (true) {
val event = awaitPointerEvent()
val change = event.changes.fastFirstOrNull { it.id == pointerId }
if (change != null && change.positionChanged()) {
// Only count as drag if movement exceeds touch slop
if (!dragTriggered) {
val totalDragX = change.position.x - down.position.x
val totalDragY = change.position.y - down.position.y
val totalDrag = sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
if (totalDrag > touchSlop) {
dragTriggered = true
}
}
updatePosition(change.position.x, change.position.y)
change.consume()
}
if (event.changes.fastAll { !it.pressed }) {
if (!dragTriggered) {
if (isDoubleTap) {
currentOnDoubleTap?.invoke()
lastTapTime = 0L // Reset to prevent triple-tap as double
} else {
currentOnTap?.invoke()
lastTapTime = now // Only record tap time for actual taps
}
}
if (!stayPut) {
thumbOffsetX = 0f
thumbOffsetY = 0f
currentOnMove(0.0, 0.0, JoystickDirection.CENTER)
}
break
}
}
}
}
) {
val centerX = size.width / 2f
val centerY = size.height / 2f
val minDim = min(size.width, size.height)
val maxTravelDistance = (minDim / 2f) * (1f - scale)
val buttonRadius = (minDim / 2f) * scale
if (centerX == 0f || centerY == 0f) return@Canvas
// Draw Background Pad
if (padPainter != null) {
translate(left = centerX - maxTravelDistance, top = centerY - maxTravelDistance) {
with(padPainter) {
draw(size = Size(maxTravelDistance * 2, maxTravelDistance * 2))
}
}
} else {
drawCircle(
color = padColor,
radius = maxTravelDistance,
center = Offset(centerX, centerY)
)
}
// Draw Button tracking states cleanly
val posX = centerX + thumbOffsetX
val posY = centerY + thumbOffsetY
if (buttonPainter != null) {
translate(left = posX - buttonRadius, top = posY - buttonRadius) {
with(buttonPainter) {
draw(size = Size(buttonRadius * 2, buttonRadius * 2))
}
}
} else {
drawCircle(
color = buttonColor,
radius = buttonRadius,
center = Offset(posX, posY)
)
}
}
}
// Clean, O(1) mathematical lookup mapping degrees flawlessly to standard directions
private fun calculateDirection(degrees: Double): JoystickDirection {
// Normalize degrees from [-180, 180] to [0, 360)
val normalized = if (degrees < 0) degrees + 360.0 else degrees
// Shift by 22.5 degrees so that the "LEFT" sector spans across the 0/360 boundary cleanly
val shifted = (normalized + 22.5) % 360.0
// Map 45-degree chunks to their respective indices
return when ((shifted / 45.0).toInt()) {
0 -> JoystickDirection.LEFT
1 -> JoystickDirection.LEFT_UP
2 -> JoystickDirection.UP
3 -> JoystickDirection.UP_RIGHT
4 -> JoystickDirection.RIGHT
5 -> JoystickDirection.RIGHT_DOWN
6 -> JoystickDirection.DOWN
7 -> JoystickDirection.DOWN_LEFT
else -> JoystickDirection.CENTER
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="JoyStick">
<attr name="padColor" format="color" />
<attr name="buttonColor" format="color" />
<attr name="stayPut" format="boolean" />
<attr name="percentage" format="integer" />
<attr name="backgroundDrawable" format="reference" />
<attr name="buttonDrawable" format="reference" />
</declare-styleable>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">JoyStick</string>
</resources>