From bf5ae2aa7fbfb2e4b663999b849bfc93f499de81 Mon Sep 17 00:00:00 2001 From: Sky233 Date: Thu, 30 Apr 2026 14:42:21 +0800 Subject: [PATCH] =?UTF-8?q?Refactor:=20=E5=BC=95=E5=85=A5=20Miot=20?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=20Mock=20=E6=9C=BA=E5=88=B6=E4=BB=A5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=A6=BB=E7=BA=BF=E5=BC=80=E5=8F=91=E4=B8=8E?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **新增 Mock 核心类**:引入 `BaseMockMiotDeviceClient` 与 `MockMiotDeviceClient`,支持基于 `specAtt` 自动初始化属性存储,并提供属性读写与动作执行的钩子(Hook)机制。 - **支持异步模拟**:`MockPropertyHook` 支持在协程中执行,以便模拟窗帘电机等设备的异步状态切换。 - **优化 `Urn` 解析**:在解析失败时输出具体的 URN 字符串以便排查。 - **增强 `SpecAtt`**:为 `Property` 添加 `getDefaultValue()` 方法,支持根据数据类型自动生成模拟默认值。 - **集成 Mock 逻辑**: - 在 `DeviceViewModel` 中通过 `MOCK_PREFIX` 识别并自动切换至 `NormalMockMiotDeviceClient`。 - 在 `MainViewModel` 中添加了一个 Mock 窗帘设备示例。 - **UI 改进**:在设备详情页 `activity_device.xml` 中新增设备型号(Model)展示,并清理了部分布局代码。 --- .../github/miwu/ui/device/DeviceViewModel.kt | 24 ++- .../com/github/miwu/ui/main/MainViewModel.kt | 13 ++ app/src/main/res/layout/activity_device.xml | 19 ++- .../commonMain/kotlin/miwu/support/urn/Urn.kt | 2 +- .../kotlin/miwu/miot/model/att/SpecAtt.kt | 33 ++++ .../src/main/java/miwu/mock/MockMiotDevice.kt | 65 ++++++++ .../java/miwu/mock/MockMiotDeviceClient.kt | 154 ++++++++++++++++++ .../miwu/mock/NormalMockMiotDeviceClient.kt | 13 ++ .../mock/base/BaseMockMiotDeviceClient.kt | 39 +++++ 9 files changed, 346 insertions(+), 16 deletions(-) create mode 100644 miwu-support/src/main/java/miwu/mock/MockMiotDevice.kt create mode 100644 miwu-support/src/main/java/miwu/mock/MockMiotDeviceClient.kt create mode 100644 miwu-support/src/main/java/miwu/mock/NormalMockMiotDeviceClient.kt create mode 100644 miwu-support/src/main/java/miwu/mock/base/BaseMockMiotDeviceClient.kt diff --git a/app/src/main/java/com/github/miwu/ui/device/DeviceViewModel.kt b/app/src/main/java/com/github/miwu/ui/device/DeviceViewModel.kt index 500f4b6d..df013071 100644 --- a/app/src/main/java/com/github/miwu/ui/device/DeviceViewModel.kt +++ b/app/src/main/java/com/github/miwu/ui/device/DeviceViewModel.kt @@ -9,8 +9,6 @@ import com.github.miwu.utils.MiotDeviceClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow import miwu.android.icon.generated.icon.AndroidIcons import miwu.android.translate.AndroidTranslateHelper import miwu.miot.kmp.utils.to @@ -18,6 +16,8 @@ import miwu.miot.model.MiotUser import miwu.miot.model.att.SpecAtt import miwu.miot.model.miot.MiotDevice import miwu.miot.provider.MiotSpecAttrProvider +import miwu.mock.MOCK_PREFIX +import miwu.mock.NormalMockMiotDeviceClient import miwu.support.manager.MiotDeviceManager import org.koin.core.component.KoinComponent @@ -28,16 +28,23 @@ class DeviceViewModel( private val specAttrProvider: MiotSpecAttrProvider ) : AndroidViewModel(application), MiotDeviceManager.Callback, KoinComponent { private val logger = Logger() - private val device = savedStateHandle.get("device") + private val _event = Channel() + private val isMockDevice: Boolean + val device = savedStateHandle.get("device") ?.to() ?.getOrThrow() + ?.takeIf { it.specType != null } + ?.also { isMockDevice = it.specType!!.startsWith(MOCK_PREFIX) } + ?.let { + if (isMockDevice) it.copy(specType = it.specType!!.substring(MOCK_PREFIX.length)) + else it + } ?: error("MiotDevice is not found") - private val user = savedStateHandle.get("user") + val user = savedStateHandle.get("user") ?.to() ?.getOrThrow() ?: error("MiotUser is not found") - private val miotDeviceClient = MiotDeviceClient(user) - private val _event = Channel() + val miotDeviceClient = if (isMockDevice) null else MiotDeviceClient(user) val event: ReceiveChannel = _event val isFromTile = savedStateHandle.get("isFromTile") ?: false val manager by lazy { @@ -49,7 +56,8 @@ class DeviceViewModel( AndroidCache(application), AndroidTranslateHelper, Dispatchers.Main, - this + this, + ::NormalMockMiotDeviceClient ) } @@ -80,6 +88,6 @@ class DeviceViewModel( } sealed interface Event { - object DeviceInitiated: Event + object DeviceInitiated : Event } } \ No newline at end of file diff --git a/app/src/main/java/com/github/miwu/ui/main/MainViewModel.kt b/app/src/main/java/com/github/miwu/ui/main/MainViewModel.kt index b619d5cb..e06acea9 100644 --- a/app/src/main/java/com/github/miwu/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/github/miwu/ui/main/MainViewModel.kt @@ -13,6 +13,7 @@ import com.github.miwu.ui.main.state.FragmentState.Normal import com.github.miwu.utils.Logger import fr.haan.resultat.fold import kotlinx.coroutines.flow.map +import miwu.mock.MockMiotDevice class MainViewModel( @@ -27,6 +28,18 @@ class MainViewModel( val home = miotRepository.currentHome val scenes = home.map { it.getOrNull()?.scenes.orEmpty() }.asLiveData() val devices = home.map { it.getOrNull()?.devices.orEmpty() } + .map { + it.toMutableList().apply { + add( + MockMiotDevice( + name = "Mock 窗帘", + did = "abcdef123456", + model = "cmjd.curtain.cmx82", + specType = "urn:miot-spec-v2:device:curtain:0000A00C:cmjd-cmx82:1:0000D031" + ) + ) + } + } .map { device -> device.sortedWith( compareBy( diff --git a/app/src/main/res/layout/activity_device.xml b/app/src/main/res/layout/activity_device.xml index 06d308d2..c273e965 100644 --- a/app/src/main/res/layout/activity_device.xml +++ b/app/src/main/res/layout/activity_device.xml @@ -8,6 +8,7 @@ + @@ -27,8 +28,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginBottom="20dp" - android:orientation="vertical" - tools:ignore="UseCompoundDrawables"> + android:orientation="vertical"> + + - - - - - + + + + + - if (parts.size < 5 || parts[0] != "urn") error("Invalid URN string") + if (parts.size < 5 || parts[0] != "urn") error("Invalid URN string: $str") val namespace = parts[1] val type = parts[2] if (type !in validType) error("Invalid type of urn") diff --git a/miot-api/src/commonMain/kotlin/miwu/miot/model/att/SpecAtt.kt b/miot-api/src/commonMain/kotlin/miwu/miot/model/att/SpecAtt.kt index 596c01a9..edc9e8c2 100644 --- a/miot-api/src/commonMain/kotlin/miwu/miot/model/att/SpecAtt.kt +++ b/miot-api/src/commonMain/kotlin/miwu/miot/model/att/SpecAtt.kt @@ -40,6 +40,9 @@ data class SpecAtt( var descriptionTranslation: String = "" } + /** + * @param valueRange 0: min, 1: max, 2: step + */ @Serializable data class Property( @SerialName("access") val access: List, @@ -56,6 +59,36 @@ data class SpecAtt( @Transient var descriptionTranslation: String = "" + /** + * bool 布尔值: true/false + * uint8 无符号8位整型 + * uint16 无符号16位整型 + * uint32 无符号32位整型 + * int8 有符号8位整型 + * int16 有符号16位整型 + * int32 有符号32位整型 + * int64 有符号64位整型 + * float 浮点数 + * string 字符串 + */ + fun getDefaultValue(): Any { + valueList?.firstOrNull()?.value?.let { return it } + val rangeMin = valueRange?.firstOrNull() + return when (type) { + "bool" -> false + "string" -> "" + "uint8" -> (rangeMin as? Int) ?: 0 + "uint16" -> (rangeMin as? Int) ?: 0 + "uint32" -> (rangeMin as? Long) ?: 0L + "int8" -> (rangeMin as? Int) ?: 0 + "int16" -> (rangeMin as? Int) ?: 0 + "int32" -> (rangeMin as? Int) ?: 0 + "int64" -> (rangeMin as? Long) ?: 0L + "float" -> (rangeMin as? Number)?.toFloat() ?: 0f + else -> rangeMin ?: 0 + } + } + @Serializable data class Value( @SerialName("description") val description: String, diff --git a/miwu-support/src/main/java/miwu/mock/MockMiotDevice.kt b/miwu-support/src/main/java/miwu/mock/MockMiotDevice.kt new file mode 100644 index 00000000..b03ec07b --- /dev/null +++ b/miwu-support/src/main/java/miwu/mock/MockMiotDevice.kt @@ -0,0 +1,65 @@ +package miwu.mock + +import miwu.miot.model.miot.MiotDevice +import miwu.miot.model.miot.MiotDeviceExtra + +const val MOCK_PREFIX = "mock-" + +/** + * 构建 MockMiotDevice + * + * Mock 设备时, 填入默认的 specType 即可, 会自动加入 [MOCK_PREFIX] + * + * @param name 设备名称 + * @param did 设备 ID + * @param model 设备型号 + * @param isOnline 是否在线 + * @param mac 设备 MAC 地址 + * @param uid 设备归属用户 ID + */ +@Suppress("FunctionName") +fun MockMiotDevice( + name: String, + did: String, + model: String, + specType: String, + isOnline: Boolean = true, + mac: String = "00:00:00:00:00:00", + uid: String = "114514" +): MiotDevice { + return MiotDevice( + bssid = "", + cnt = null, + comFlag = 0, + did = did, + extra = MiotDeviceExtra( + fwVersion = null, + isSetPinCode = null, + isSubGroup = null, + mcuVersion = null, + pinCodeType = null, + platform = null, + showGroupMember = null + ), + freqFlag = false, + hideMode = 0, + isOnline = isOnline, + lastOnline = null, + latitude = "", + localIp = null, + longitude = "", + mac = mac, + model = model, + name = name, + orderTime = 0, + parentId = "", + permitLevel = 0, + pid = 0, + rssi = 0, + showMode = 0, + specType = MOCK_PREFIX + specType, + ssid = null, + token = "", + uid = uid.toLong() + ) +} \ No newline at end of file diff --git a/miwu-support/src/main/java/miwu/mock/MockMiotDeviceClient.kt b/miwu-support/src/main/java/miwu/mock/MockMiotDeviceClient.kt new file mode 100644 index 00000000..b2c64fec --- /dev/null +++ b/miwu-support/src/main/java/miwu/mock/MockMiotDeviceClient.kt @@ -0,0 +1,154 @@ +package miwu.mock + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import miwu.miot.att.get.GetAtt +import miwu.miot.att.set.SetAtt +import miwu.miot.att.set.piid +import miwu.miot.att.set.siid +import miwu.miot.att.set.value +import miwu.miot.model.att.DeviceAtt +import miwu.miot.model.att.SpecAtt +import miwu.miot.model.miot.MiotDevice +import miwu.mock.base.BaseMockMiotDeviceClient + +typealias MockStore = Map> +typealias MockAction = MutableMap> +typealias MockActionHook = (store: MockStore, input: Array) -> Any +typealias MockProperty = MutableMap> +typealias MockPropertyHook = suspend (store: MockStore, origin: Any) -> Unit + +typealias MockMiotDeviceClientBuilder = (mockScope: CoroutineScope, specAtt: SpecAtt, device: MiotDevice) -> MockMiotDeviceClient + +/** + * 用于在测试中模拟 [miwu.miot.client.MiotDeviceClient] 行为的抽象基类。 + * + * 它基于 [specAtt] 自动初始化一个存放设备属性值的内部存储 [mockStore], + * 并通过 [registerProperty] 与 [registerAction] 提供可插拔的钩子机制, + * 以便测试者控制设备的属性读写与动作执行行为,无需依赖真实设备。 + * + * 内部行为简述: + * - 属性读取 ([onGet]): 直接返回 [mockStore] 中对应 `siid/piid` 的当前值 + * - 属性写入 ([onSet]): + * 如果未注册对应的 [MockPropertyHook],则同步更新 [mockStore] + * 否则取消该属性上一个未完成的 Job,并在 [mockScope] 中启动新的协程执行钩子 + * 钩子可以自由读写 [mockStore] 以实现延迟写入、条件写入等异步行为 + * - 动作执行 ([onAction]): 调用对应 `siid/aiid` 注册的 [MockActionHook] 并返回其结果; + * 若未注册则返回成功但数据为空的 [Result] + * + * 基类设计思路: + * 部分设备属性在受控后并非立即稳定,而是存在异步过渡状态。 + * 以窗帘电机(如 [miwu.device.Curtain])为例: + * - 开窗时,电机先进入“开窗中”,待到位后自动转为“停止”; + * - 关窗时,电机先进入“关窗中”,待到位后自动转为“停止”。 + * 为模拟此类延迟自更新行为,`MockPropertyHook` 在协程中执行, + * 允许测试在钩子内通过 `mockScope` 延迟写入 `mockStore`。 + * + * @param mockScope 用于执行模拟协程的 [CoroutineScope],例如在 [MockPropertyHook] 中延迟更新属性 + * @param specAtt 设备的规格信息,用于初始化默认属性值及注册钩子时的名称查找 + * @param device 模拟的设备实例 + */ +abstract class MockMiotDeviceClient( + val mockScope: CoroutineScope, + val specAtt: SpecAtt, + device: MiotDevice, +) : BaseMockMiotDeviceClient(device) { + private val mockStore: MockStore = + specAtt.services.associate { service -> + service.properties + .orEmpty() + .associate { it.iid to it.getDefaultValue() } + .toMutableMap() + .let { service.iid to it } + } + private val mockAction: MockAction = mutableMapOf() + private val mockProperty: MockProperty = mutableMapOf() + private val mockJob: MutableMap, Job> = mutableMapOf() + abstract fun onInit() + + /** + * 注册一个 MockProperty + */ + fun registerProperty( + serviceName: String, + propertyName: String, + property: MockPropertyHook + ) { + val siid: Int + val aiid: Int + specAtt.services + .firstOrNull { it.type == serviceName } + ?.also { siid = it.iid } + ?.actions + ?.firstOrNull { it.type == propertyName } + ?.also { aiid = it.iid } + ?: return + mockProperty.getOrPut(siid) { mutableMapOf() }[aiid] = property + } + + /** + * 注册一个 MockAction + */ + fun registerAction( + serviceName: String, + actionName: String, + action: MockActionHook + ) { + val siid: Int + val aiid: Int + specAtt.services + .firstOrNull { it.type == serviceName } + ?.also { siid = it.iid } + ?.actions + ?.firstOrNull { it.type == actionName } + ?.also { aiid = it.iid } + ?: return + mockAction.getOrPut(siid) { mutableMapOf() }[aiid] = action + } + + override suspend fun onGet(att: Array): Result = + runCatching { + DeviceAtt( + code = 0, + message = "", + result = att.map { info -> + DeviceAtt.Att( + did = miotDevice.did, + iid = "", + siid = info.first, + piid = info.second, + value = mockStore[info.first]?.get(info.second), + code = 0, + updateTime = null, + exeTime = 0 + ) + }.let { ArrayList(it) } + ) + } + + override suspend fun onSet(att: Array): Result = + runCatching { + for (entry in att) { + val mockFun = mockProperty[entry.siid]?.get(entry.piid) + if (mockFun == null) { + mockStore[entry.siid]?.set(entry.piid, entry.value) + continue + } + val newJob = mockScope.launch { + mockFun(mockStore, entry.value) + }.apply { + invokeOnCompletion { + mockJob.remove(entry.siid to entry.piid) + } + } + mockJob[entry.siid to entry.piid]?.cancel() + mockJob[entry.siid to entry.piid] = newJob + } + } + + override suspend fun onAction(siid: Int, aiid: Int, vararg input: Any): Result = + runCatching { + mockAction[siid]?.get(aiid)?.invoke(mockStore, input) + } +} \ No newline at end of file diff --git a/miwu-support/src/main/java/miwu/mock/NormalMockMiotDeviceClient.kt b/miwu-support/src/main/java/miwu/mock/NormalMockMiotDeviceClient.kt new file mode 100644 index 00000000..60b92724 --- /dev/null +++ b/miwu-support/src/main/java/miwu/mock/NormalMockMiotDeviceClient.kt @@ -0,0 +1,13 @@ +package miwu.mock + +import kotlinx.coroutines.CoroutineScope +import miwu.miot.model.att.SpecAtt +import miwu.miot.model.miot.MiotDevice + +class NormalMockMiotDeviceClient( + mockScope: CoroutineScope, + specAtt: SpecAtt, + device: MiotDevice, +) : MockMiotDeviceClient(mockScope, specAtt, device) { + override fun onInit() = Unit +} \ No newline at end of file diff --git a/miwu-support/src/main/java/miwu/mock/base/BaseMockMiotDeviceClient.kt b/miwu-support/src/main/java/miwu/mock/base/BaseMockMiotDeviceClient.kt new file mode 100644 index 00000000..54a23d46 --- /dev/null +++ b/miwu-support/src/main/java/miwu/mock/base/BaseMockMiotDeviceClient.kt @@ -0,0 +1,39 @@ +package miwu.mock.base + +import miwu.miot.att.get.GetAtt +import miwu.miot.att.set.SetAtt +import miwu.miot.client.MiotDeviceClient +import miwu.miot.model.att.DeviceAtt +import miwu.miot.model.miot.MiotDevice + +/** + * 如果想要实现 MockMiotDeviceClient 就必须继承该类去实现, + * 用于模拟设备的属性获取和设置, onAction 可以视情况继承实现, 在需要返回数据的时候就实现 + * + * @see [miwu.miot.client.MiotDeviceClient] + */ +abstract class BaseMockMiotDeviceClient(val miotDevice: MiotDevice) : MiotDeviceClient { + abstract suspend fun onGet(att: Array): Result + + abstract suspend fun onSet(att: Array): Result + + open suspend fun onAction(siid: Int, aiid: Int, vararg input: Any): Result = + runCatching { } + + override suspend fun get( + device: MiotDevice, + att: Array + ): Result = onGet(att) + + override suspend fun set( + device: MiotDevice, + att: Array + ): Result = onSet(att) + + override suspend fun action( + device: MiotDevice, + siid: Int, + aiid: Int, + vararg input: Any + ): Result = onAction(siid, aiid, input) +} \ No newline at end of file