Author SHA1 Message Date
mazengfeiandClaude Fable 5 03e6c9a6bb feat(log): 增加日志定期清理机制,防止长期运行日志无限累加
- FileLogger 新增过期清理(保留7天)及单文件100MB写入上限
- CrashHandler 激活崩溃日志清理并补充异常保护
- App 启动清理 + 运行中每24小时定时清理,覆盖设备长期不断电场景

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:57:32 +08:00
mazengfei c77c32fde1 refactor(model): 删除已废弃的分页响应包装类PageData
- 移除PageData数据类及其相关注释
- 精简代码库,减少不必要的数据封装
- 为后续分页功能重构做好准备
- 保持代码结构清晰,提升可维护性
2026-08-09 17:59:22 +08:00
mazengfei dec5ecc100 refactor(api): 优化净菜包和餐品净菜包接口返回结构
- 移除净菜包和餐品净菜包接口中分页包装PageData,接口直接返回列表数组
- 修改相关调用处,取消对PageData的类型转换,直接处理列表数据
- 清理无用的PageData导入语句
- 保持接口功能和调用逻辑一致,提升代码简洁性和易维护性
2026-08-09 17:59:04 +08:00
mazengfei 5dc0bb2ec5 feat(model): 添加分页响应包装和分页请求体数据类
- 新增 PageData 泛型数据类,封装分页响应数据结构
- 增加 list、total、pageNum、pageSize 四个分页字段注释说明
- 新增 PageOptionsReq 数据类,用于分页请求参数封装
- pageNum、pageSize 必填,keyword 用于搜索或列表返回
- 适配净菜包及餐品净菜包下拉接口请求需求
2026-08-09 17:42:10 +08:00
mazengfei c3682d1ed3 refactor(api): 更新净菜包和餐品净菜包接口支持分页请求
- 使用 POST 请求和 JSON Body 替换 GET 请求,支持分页参数传递
- 更新接口返回类型为分页结构 PageData
- 在对应数据模型中新增分页请求参数类 PageOptionsReq 引入
- 增加 CleanPackageOption 和 MealPackageOption 模型字段以支持新需求
- 调整 NetViewModelV2 中调用接口逻辑,传递分页参数
- 修改 ShelfV2Activity 中数据处理逻辑,适配分页返回数据结构
- 更新全局常量 PROD_BASE_URL 地址
2026-08-09 17:07:13 +08:00
lvmeng e902f2869f fix(home): 修复行列数量赋值错误问题
- 纠正了 rows 和 columns 变量的赋值逻辑
- 将 rows 正确赋值为 cabinet.horizontalRows
- 将 columns 正确赋值为 cabinet.verticalCount
- 修正了因后端返回数据结构导致的显示错误
2026-06-26 09:27:50 +08:00
lvmengandClaude Opus 4.7 867cbb77be refactor(home): 用FlexboxLayoutManager替换GridLayoutManager,支持动态行列布局
FlexboxLayoutManager(COLUMN+WRAP)天然列优先排列,数据无需重排。
item宽高在拿到接口行列数后直接计算,适配任意rows×cols配置。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 09:23:10 +08:00
mazengfei 8aaba07cb6 perf(home): 调整货架商品同步到服务器的时间间隔
- 将定时任务的执行间隔从5秒调整为2分钟
- 保持初始延迟时间不变(10秒)
- 优化服务器同步频率以减少资源消耗
2026-06-04 18:10:17 +08:00
mazengfei 4f1a9dd0a1 refactor(home): 重构货架重量数据模型和显示逻辑
- 将实际重量计算统一到weight字段,移除actualQty和totalWeightG字段
- 修改HomeV2Activity中的重量数据处理逻辑,统一转换为千克单位
- 更新ShelfV2Activity中重量显示格式,优化单位转换逻辑
- 调整ShelfV2Adapter中重量显示逻辑,统一使用weight字段进行展示
- 在日志记录中增加温度信息输出
- 移除废弃的条件判断逻辑,简化代码结构
2026-06-04 17:28:23 +08:00
lvmeng 694e20023a refactor(layout): 优化网格布局工具和货架界面排序逻辑
- 新增 getLayoutManagerV2 方法用于创建垂直方向的网格布局管理器
- 修改 initListSortV2 方法支持动态行列参数控制排序逻辑
- 添加 submitListSortV2 方法用于提交时按槽位号排序
- 重构 HomeV2Activity 中的 UI 更新逻辑,支持动态行列配置
- 修复 RecyclerView 初始化时机问题,避免重复设置适配器
- 优化 ShelfV2Activity 中的清空操作,添加加载提示和自动关闭功能
- 完善槽位状态管理,支持空置和存放中状态标识
2026-06-04 14:32:08 +08:00
mazengfei ac17fca325 fix(home): 解决格口数据更新和权重计算问题
- 将超时时间从5分钟调整为1分钟
- 优化格口数据匹配逻辑,提高查找效率
- 根据物品类型分别处理重量数据存储
- 添加JSON序列化支持用于调试日志
- 修复itemType字段类型不一致问题
- 优化重量显示计算逻辑并简化条件判断
- 修复代码格式化问题
2026-06-04 11:19:25 +08:00
lvmeng 4b8e5f84e6 refactor(net): 重构网络请求配置以支持动态域名切换
- 在 ApiServiceV2 中使用 @Url 注解实现动态 URL 配置
- 添加 DynamicBaseUrlInterceptor 拦截器用于实时替换基础 URL
- 更新环境切换对话框中的环境名称和对应的 URL 映射关系
- 修改 UrlConfig 中的常量为变量并包含完整的基础 URL 路径
- 在 HttpManager 中为两个 API 服务实例都添加动态域名拦截器
- 调整环境配置常量定义顺序以匹配新的环境切换逻辑
2026-06-03 16:30:05 +08:00
lvmeng d6ba6cf527 feat(model): 更新货柜初始化结果和净菜包选项数据结构
- 在 CabinetInitResult 中添加 horizontalRows 和 verticalCount 字段
- 为 CleanPackageOption 和 MealPackageOption 添加 orderNo、foodId、needCount、needQtyKg 等字段
- 修改 CleanPackageOption 的 itemType 为 1,MealPackageOption 的 itemType 为 2
- 在 ShelfModelV2 中添加 cleanOrderNo 字段用于关联净菜订单号
- 更新 SyncItem 数据结构,添加 cleanOrderNo 字段
- 移除 HomeV2Activity 中的测试代码和硬编码逻辑
- 修改格口编号显示格式,补零显示两位数
- 调整定时任务执行频率,缩短同步间隔时间
2026-05-28 17:39:04 +08:00
lvmeng 4408acfca4 refactor(net): 注释掉设备令牌和配置相关功能
- 注释掉 getDeviceToken 方法及其相关逻辑
- 注释掉 getDeviceConfig 方法及其相关逻辑
- 注释掉 getAccessToken 方法及其相关逻辑
- 保留了 UiState 相关的状态流定义
- 维持了其他网络请求方法的正常功能
2026-05-27 19:55:02 +08:00
lvmengandClaude Sonnet 4.6 b0739605b9 feat(v2): 新增 V2 货柜接口层、页面及数据同步功能
- 新增 ApiServiceV2 / NetViewModelV2 承载 v1.1 接口(初始化、净菜包/餐品净菜包选项、数据同步)
- 新增 CabinetRequestInterceptor 独立 Retrofit 实例
- 新增 HomeV2Activity + ShelfV2Activity 及对应布局
- 新增 ShelfV2Adapter / CleanPackageAdapter / MealPackageAdapter
- 新增 ShelfModelV2 / CabinetInitResult / SyncBody / SyncItem / CleanPackageOption / MealPackageOption 数据模型
- HomeV2Activity 实现 syncShelfGoodsToServer 定时同步逻辑
- GridLayoutTool 新增 initListSortV2 支持 ShelfModelV2 排序

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:54:06 +08:00
lvmeng d524da1af3 refactor(net): 注释掉设备token和配置信息相关API接口
- 将getDeviceToken接口注释掉
- 将getDeviceInfo接口注释掉
- 将getAccessToken接口注释掉
- 保留了原有的文档注释
- 维持了文件结构完整性
2026-05-27 13:51:23 +08:00
lvmengandClaude Sonnet 4.6 967d06ce1e docs(serial): 更新 ScaleManager 类注释
- 同步典型用法为当前链式 start() 写法
- 职责说明补充 Channel 发送队列描述
- release() 说明补充"清空队列"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:47:56 +08:00
lvmengandClaude Sonnet 4.6 d5c6ec92d4 refactor(serial): sendCmd 改用 Channel 保证串口写入顺序
- 新增 sendChannel(无界 Channel)作为发送队列
- openPort() 启动单一消费者协程顺序消费队列,避免并发写串口
- sendCmd() 改为 trySend() 入队,不再每次创建新协程
- release() 关闭 Channel,确保消费者协程正常退出

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:02:15 +08:00
lvmengandClaude Sonnet 4.6 7b28eb03d6 refactor(serial): 清理废弃类并复用现有工具函数
- 删除 SerialProtocolHandler(旧版协议解析器,已无任何引用)
- 删除 SerialFrameParser(仅被 SerialProtocolHandler 使用,随其废弃)
- ScaleFrameParser.parseFrame 改用项目已有的 String.hexToBytes(),移除重复的私有 hexToByteArray()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:00:08 +08:00
lvmengandClaude Sonnet 4.6 4d545ceb46 refactor(serial): 将 SerialPortManager 合并到 ScaleManager
- 删除 SerialPortManager,串口生命周期(open/startReceive/release)内嵌到 ScaleManager
- ScaleManager 改为 object 单例,去除 .instance 调用
- init() 简化为只接收 activateCode,去除 deviceId + activateMap 查表逻辑
- 新增 start(preHook) 方法合并 open + startReceive,支持链式调用到末尾
- BaseActivity.sendCmd 改为直接调用 ScaleManager.sendCmd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:59:19 +08:00
mazengfeiandClaude Sonnet 4.6 0eede1059c feat(serial): 封装 ScaleManager 替换现有秤功能逻辑
新增 SDK 核心类:
- ScaleProtocolConstants:协议常量(命令字节、帧偏移)
- ScaleFrameParser:分片拼装、帧解析、BCD重量解析(修复旧代码 hex 误用问题)
- ScaleFrame / ScaleWeight / HeartbeatData / StatusData:数据模型
- ScaleManager:单例入口,支持链式回调注册、自动应答、指令构建
- ScaleFrameParserTest:基于实际抓包数据的单元测试

业务层替换:
- HomeActivity:移除 SerialProtocolHandler/SerialFrameParser,改用 ScaleManager 回调
- BaseActivity:sendCmd() 保持调用 SerialPortManager,与 ScaleManager.sendAction 独立
- ShelfActivity:秤置零、获取状态指令改用 ScaleManager 构建方法

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 13:54:02 +08:00
lvmeng 8145eb588b refactor(serial): 将串口配置常量重命名为大写格式
- 将 portName 常量重命名为 PORT_NAME
- 将 baudRate 常量重命名为 BAUD_RATE
- 更新代码中对这些常量的引用以使用新的命名约定
- 保持串口通信功能完全不变
2026-05-21 09:01:27 +08:00
mazengfei c91d5656aa 修改旧版设备秤数量导致数据显示不准的问题 2026-05-20 16:50:53 +08:00
mazengfei 532b22a5c6 设备支持3列显示,统一处理显示和提交数据顺序 2026-05-20 15:36:28 +08:00
mazengfei e9d04bfb70 优化启动页面样式 2026-04-28 16:52:54 +08:00
mazengfei afba5220b3 首页设置禁止返回按钮 2026-04-28 16:41:21 +08:00
lvmeng 4ace12c6c7 style(ui): 调整加载动画尺寸并替换为矢量资源
- 将加载动画ImageView尺寸从160dp调整为120dp
- 替换loading_spinner为新的loading矢量资源
- 添加loading.xml矢量动画资源文件
- 注释掉HomeActivity中的临时报告对话框逻辑
- 简化HomeActivity中的RespData类型转换代码
2026-04-28 15:37:47 +08:00
mazengfei ee36869003 优化 2026-04-07 17:19:12 +08:00
lvmengandClaude Sonnet 4.6 c23c215941 fix: 修复 noDataWatchdogTask 潜在的内存泄漏问题
在 Runnable 中添加生命周期检查,防止 Activity 销毁后任务继续执行。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:58:22 +08:00
lvmengandClaude Sonnet 4.6 3d67dd2c2d refactor: 优化货架列表转换逻辑并支持奇数长度处理
- 移除 subList 创建,直接通过索引访问元素
- 修复奇数长度列表的处理逻辑
- 保持单次循环,提升性能

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:53:00 +08:00
mazengfeiandClaude Sonnet 4.6 133f309d4b refactor: 统一时钟管理并优化状态栏显示逻辑
- 在 BaseActivity 中实现统一的时钟管理机制,避免各子类重复实现
- LogActivity 和 ShelfActivity 覆写 onClockTick() 自定义状态栏显示
- HomeActivity 移除重复的时间更新逻辑,启用右侧状态栏菜单
- 提取协议常量到 ProtocolConstants,提高代码可维护性
- 优化开锁逻辑判断,改为基于 deviceId 的动态判断
- 新增右侧状态栏弹出菜单布局文件

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:07:41 +08:00
mazengfeiandClaude Sonnet 4.6 3e5c7c6caa refactor(HomeActivity): 修复内存泄漏并优化错误处理
1. 修复内存泄漏问题
   - 在 onDestroy 中清理所有 Handler 回调
   - 在 onDestroy 中释放 Dialog 引用

2. 增强类型安全
   - updateUI 中增加类型检查和日志
   - updateOverdueState 中增加完整的类型检查

3. 优化串口异常处理
   - 串口数据处理异常时向用户提示

4. 消除代码重复
   - 删除冗余的 saveShelfGoodsList 方法
   - 定时任务直接调用 syncShelfGoodsToServer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:19:45 +08:00
mazengfeiandClaude Sonnet 4.6 04fe3ec2c1 refactor: 全面重构 HomeActivity 架构并修复多项 Bug
- 新增 SerialProtocolHandler 类,将串口协议解析逻辑从 HomeActivity 剥离
- ProtocolConstants 补充命令码常量(CMD_*)及帧解析偏移常量
- 修复 NetViewModel.getDeviceConfig 更新错误 StateFlow 的 Bug
- 修复 getWeightInfo 中 toInt() 整数溢出崩溃风险,改用 toLongOrNull(16)
- 升级废弃的 getSerializableExtra,使用 Build.VERSION.SDK_INT 版本分支
- BaseActivity 提取 onUserInactive() 虚方法,消除对子类 HomeActivity 的直接引用
- 修复 BaseActivity 中错误的 LIGHT_OPEN/CLOSE 导入(改从 ProtocolConstants 导入)
- repeatOnLifecycle 由 CREATED 改为 STARTED,避免 UI 不可见时无效更新
- updateOverdueState 从回调风格改为 StateFlow,与项目其他接口保持一致
- 提取 syncShelfGoodsToServer() 消除重复的 ShelfBody 构建逻辑
- 清理 NetViewModel 和 BaseActivity 中大段注释死代码

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:26:05 +08:00
lvmengandClaude Sonnet 4.6 9a795b9710 feat(HomeActivity): 新增串口无数据超时提示弹窗
超过5分钟未收到串口数据时,自动弹窗提示用户考虑断电重启设备;
收到新数据后自动关闭弹窗并重置计时。
同步提交串口协议常量(ProtocolConstants)与帧解析器(SerialFrameParser)重构代码。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:18:00 +08:00
mazengfei fff6a2a3b3 配置生产域名 2026-03-31 19:16:56 +08:00
lvmeng 3b79f6d6aa feat(config): 实现应用基础URL配置管理与环境切换功能
- 引入GlobalData对象统一管理应用基础URL配置
- 添加TEST/UAT/PROD三套环境切换弹窗功能
- 在BaseActivity中集成双击右上角状态栏显示环境切换对话框
- 将网络请求配置从App.configUrl迁移至GlobalData.appBaseUrl
- 扩展SpTool工具类支持baseUrl本地存储
- 增加HTTP连接和写入超时时间至30秒
- 更新构建配置启用BuildConfig支持
- 添加环境切换相关UI资源文件和样式定义
2026-03-31 14:06:04 +08:00
lvmeng 76b67b545d Merge remote-tracking branch 'origin/dev_优化_基于分支代码备份' 2026-03-31 10:58:32 +08:00
lvmeng 3bd60990b9 Revert "重构 HomeActivity:修复多项稳定性与架构问题"
This reverts commit 85903eb646.
2026-03-31 10:57:46 +08:00
lvmeng a116c4fc6c Revert "重构:拆分 HomeActivity 职责,分层解耦串口与业务逻辑"
This reverts commit e6cfc4dff2.
2026-03-31 10:57:40 +08:00
lvmengandClaude Sonnet 4.6 e6cfc4dff2 重构:拆分 HomeActivity 职责,分层解耦串口与业务逻辑
- 新建 SerialEventListener:定义串口协议解析回调接口契约
- 新建 SerialProtocolParser:迁移全部协议解析逻辑(拼包、12种命令分发、重量解析),无 Android 依赖,可单元测试
- 新建 SerialViewModel:管理串口生命周期(open/close)、Channel 数据传递,暴露 weightFlow / tempCtrlEventFlow / deviceStatusRefreshFlow
- 扩展 HomeViewModel:迁入定时任务(保存货架数据、刷新过期状态),改用 viewModelScope 管理,新增 deviceName 属性与 overdueUpdateFlow
- 精简 HomeActivity:从 835 行削减至 255 行,仅保留 UI 协调职责,所有串口与业务逻辑由 ViewModel 层处理

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:41:01 +08:00
lvmengandClaude Sonnet 4.6 85903eb646 重构 HomeActivity:修复多项稳定性与架构问题
架构优化:
- 新增 HomeViewModel,将 companion object 中的共享状态(shelfItems、
  weightMap、selectedShelfIndex、temperatureC、humidity)迁移至 ViewModel,
  消除 Activity 重建时的状态残留与全局状态污染问题

协程修复:
- repeatOnLifecycle(CREATED) 改为 STARTED,确保 Flow 收集与 UI 可见性
  生命周期对齐,进入后台时自动停止订阅
- 串口数据接收改用 Channel<String> + 单一消费协程,替代多 launch 并发模式,
  彻底消除 StringBuilder(serialReader)的并发竞争问题

稳定性修复:
- 修复 tipDialog 从未赋值导致 Dialog 功能失效及 onDestroy 窗口泄漏问题
- 修复 weightHex.toInt() 十进制解析错误,改为 toIntOrNull(16) 并添加兜底
- 修复 updateOverdueState 按索引访问接口数据导致的越界风险,
  改为按 deviceNo 精确匹配,同时修正交叉排列导致的状态串位问题
- 废弃 API getSerializableExtra 替换为版本分支兼容写法(minSdk 25+)

依赖升级:
- androidx.core-ktx 1.10.1 → 1.12.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:45:44 +08:00
56 changed files with 4791 additions and 935 deletions
+3
View File
@@ -44,6 +44,7 @@ android {
}
buildFeatures {
viewBinding = true
buildConfig = true
}
ndkVersion = "29.0.13846066"
@@ -86,6 +87,8 @@ dependencies {
implementation("org.greenrobot:eventbus:3.3.1")
implementation("com.google.android.flexbox:flexbox:3.0.0")
// implementation("io.github.jeremyliao:live-event-bus-x:1.8.0")
// implementation("com.hoho:android-usb-serial:1.3.0")
// implementation("io.github.jeadyx:jserialport:1.5")
+3 -1
View File
@@ -43,8 +43,10 @@
android:name=".activity.ShelfActivity"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name="com.shuwei.intelligent.shelves.activity.SettingActivity" />
<!-- <activity android:name="com.shuwei.intelligent.shelves.activity.SettingActivity" />-->
<activity android:name="com.shuwei.intelligent.shelves.activity.LogActivity" />
<activity android:name="com.shuwei.intelligent.shelves.activity.HomeV2Activity" />
<activity android:name="com.shuwei.intelligent.shelves.activity.ShelfV2Activity" />
<receiver
android:name=".utils.BootReceiver"
@@ -5,8 +5,11 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import com.shuwei.intelligent.shelves.utils.BootReceiver
import com.shuwei.intelligent.shelves.utils.CrashHandler
import com.shuwei.intelligent.shelves.utils.FileLogger
/**
* @author: star
@@ -19,10 +22,22 @@ class App : Application() {
super.onCreate()
app = this
CrashHandler.init(this)
// 启动时立即清理一次过期日志
cleanupLogs()
// 设备长期不断电、进程不会重新启动,运行中每24小时定期清理一次
logCleanupHandler.postDelayed(logCleanupRunnable, LOG_CLEANUP_INTERVAL)
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
registerReceiver(BootReceiver(), filter)
}
/**
* 清理过期的日志文件:文件日志与崩溃日志均保留最近7天
*/
private fun cleanupLogs() {
FileLogger.cleanupExpiredLogs(this)
CrashHandler.getInstance()?.cleanupOldCrashReports()
}
companion object {
private lateinit var app: App
var canteenId = "0"
@@ -36,6 +51,19 @@ class App : Application() {
@Volatile
private var sharedPref: SharedPreferences? = null
/** 日志定期清理间隔:24小时,与日志文件按天滚动对齐 */
private const val LOG_CLEANUP_INTERVAL = 24L * 60 * 60 * 1000
private val logCleanupHandler = Handler(Looper.getMainLooper())
/** 日志定期清理任务:执行后重新调度自身,形成循环 */
private val logCleanupRunnable = object : Runnable {
override fun run() {
getInstance().cleanupLogs()
logCleanupHandler.postDelayed(this, LOG_CLEANUP_INTERVAL)
}
}
fun getInstance(): App {
return app
}
@@ -0,0 +1,39 @@
package com.shuwei.intelligent.shelves
object GlobalData {
// /**
// * 设备id
// */
// var deviceId: String = ""
// /**
// * app版本号
// */
// var appVersion: String = "1"
var appBaseUrl: String = ""
/**
* 具体业务 BaseUrl
*/
// const val LOCAL_BASE_URL = "http://192.168.1.201:14801"
const val LOCAL_BASE_URL = "http://192.168.10.101:24801"
const val TEST_BASE_URL = "https://dev.yixiong-tech.com:8081"
const val PROD_BASE_URL = "https://platform-api.uat.shuziweidao.com"
}
/**
* 全局常量
*/
object Constants {
}
/**
* key
*/
object GlobalKey {
const val KEY_BASE_URL = "baseUrlKey"
}
@@ -3,11 +3,11 @@ package com.shuwei.intelligent.shelves.activity
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.SparseIntArray
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
@@ -25,126 +25,106 @@ import com.shuwei.intelligent.shelves.model.SendWeightEvent
import com.shuwei.intelligent.shelves.model.ShelfBody
import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.model.ShelfResult
import com.shuwei.intelligent.shelves.model.WeightEvent
import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.net.NetViewModel
import com.shuwei.intelligent.shelves.net.RespData
import com.shuwei.intelligent.shelves.net.UiState
import com.shuwei.intelligent.shelves.net.apiService
import com.shuwei.intelligent.shelves.serial.SerialPortManager
import com.shuwei.intelligent.shelves.utils.HexUtils
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.C_TEMP_CMD
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.START_TEMP_CTRL_CMD
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.utils.GridLayoutTool
import com.shuwei.intelligent.shelves.utils.IntervalExecutor
import com.shuwei.intelligent.shelves.utils.binaryToHex
import com.shuwei.intelligent.shelves.utils.ext.copyTextToClipboard
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toJsonString
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible
import com.shuwei.intelligent.shelves.utils.hexToBinary
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class HomeActivity : BaseActivity() {
companion object {
private const val TAG = "HomeActivity"
private const val LOG_PREFIX = "receiveSerialPortData:收到"
companion object {}
private const val MM_DD_EEEE__HH_MM_SS = "MM月dd日 EEEE HH:mm:ss"
override val enableRightStatusMenu: Boolean = true
//协议头
const val HEADER = "D6DADEDB"
// 货架列表数据
private val list: MutableList<ShelfModel> = mutableListOf()
//协议尾
const val FOOTER = "FFFF"
// const val ACTIVE_CMD =
// "${HEADER}000281C5D70A7D428A4FA67B50F7DACA241938938B4B9CBD73673E9C8E7F2E15062D${FOOTER}"
// 当前选中货架下标
private var shelfIndex = 0
const val LEFT_SHELF_OPEN_CMD =
"${HEADER}020101000000000000000000000000000000000000${FOOTER}"
const val RIGHT_SHELF_OPEN_CMD =
"${HEADER}020100010000000000000000000000000000000000${FOOTER}"
// 当前温度(摄氏)
private var showTemperatureC = "0"
//const val TEMPERATURE_CMD = "${HEADER}1001${FOOTER}"
// 当前湿度
private var showHumidity = "0"
const val DEVICE_INFO_CMD = "${HEADER}0301000000000000000000000000${FOOTER}"
// 仅特定 deviceId 的部署点需要发送开锁指令
private fun isNeedOpenLock() = App.deviceId == "4787e213-90ab-3e32-88e0-ac271a937751"
const val START_TEMP_CTRL_CMD = "${HEADER}0F01360000${FOOTER}"
//const val START_CMD= "${HEADER}0F01050020${FOOTER}"
// const val ACTIVE_CMD = "${HEADER}000281C5D70A7D428948AD7254FADBB1516738938B4B9CBD73673E9D8E7F2E15062D$FOOTER"
// 410激活码
// const val ACTIVE_CMD = "${HEADER}000281C5D70A7D42894EA17650F5D0BB516838E7863C9ABD766C38958AFF0F3C062D${FOOTER}"
//1楼餐厅激活码
const val ACTIVE_CMD =
"${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}"
//华氏温度单位
const val F_TEMP_CMD = "${HEADER}0F01390000${FOOTER}"
//摄氏温度单位
const val C_TEMP_CMD = "${HEADER}0F01390001${FOOTER}"
//开灯(开 继电器1
const val LIGHT_CLOSE = "${HEADER}020102020102020000000000000000000000000000${FOOTER}"
const val LIGHT_OPEN = "${HEADER}020102020002020000000000000000000000000000${FOOTER}"
const val TEMPERATURE_CTRL = "${HEADER}0F01040000${FOOTER}"
val list: MutableList<ShelfModel> = mutableListOf()
val weightArray = SparseIntArray()
var shelfIndex = 0
// public fun getRealWeight():Double = weightArray[shelfIndex]?:0.0
// 温度
var showTemperatureC = "0"
// 湿度
var showHumidity = "0"
}
//401-false1楼餐厅-true
private var isNeedOpenLock = true
private var deviceName = ""
private lateinit var binding: ActivityHomeBinding
private val viewModel: NetViewModel by viewModels()
@SuppressLint("NotifyDataSetChanged", "HardwareIds")
// 串口协议解析器,通过回调通知本 Activity
private fun initScaleManager() {
lifecycleScope.launch {
val ok = ScaleManager
.init(activateCode = ProtocolConstants.ACTIVE_MAP[App.deviceId] ?: "")
.onHeartbeat { _, data ->
data.weights.forEach { weight ->
val shelfNo = weight.index
if (shelfNo in 1..list.size) {
list.firstOrNull { it.deviceNo == shelfNo }?.let { model ->
model.weight = weight.grams.toDouble()
val pos = list.indexOf(model)
shelfAdapter.notifyItemChanged(pos)
log("getWeightInfo: deviceNo=${model.deviceNo},realWeight=${weight.grams}")
EventBus.getDefault().post(SendWeightEvent(model.deviceNo, weight.grams))
}
}
}
}
.onTempReport { _ ->
updateLeftStatus(deviceName)
}
.onLog { message -> log(message) }
.start { data ->
lastDataReceivedTime = System.currentTimeMillis()
noDataWarningDialog?.takeIf { it.isShowing }?.dismiss()
log("receiveSerialPortData: $data")
}
log("onCreate: openState=$ok")
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityHomeBinding.inflate(layoutInflater)
EventBus.getDefault().register(this)
setBackground()
setContentView(binding.root)
onBackPressedDispatcher.addCallback(this) {
// updateLeftStatus("-°C / -%")
}
updateLeftStatus("")
initRecyclerView()
// 初始化 ScaleManager:注册回调、打开串口、启动接收
initScaleManager()
// 网络状态监听,独立协程,与串口初始化并行执行
lifecycleScope.launch {
val openState = SerialPortManager.open()
log("onCreate: openState=$openState")
if (openState) {
SerialPortManager.startReceive { data ->
// 更新UI显示接收数据
lifecycleScope.launch {
try {
receiveSerialPortData(data)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
//SerialPortManager.send(DEVICE_INFO_CMD)
}
repeatOnLifecycle(Lifecycle.State.CREATED) {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.getAccessTokenUiState.collect { state ->
when (state) {
@@ -165,61 +145,53 @@ class HomeActivity : BaseActivity() {
}
}
}
launch {
viewModel.updateOverdueStateUiState.collect { state ->
if (state is UiState.Success<*>) {
val respData = state.data as? RespData<*>
if (respData == null) {
log("updateOverdueState: 数据类型错误,期望 RespData")
return@collect
}
val shelfResult = respData.data as? ShelfResult
if (shelfResult == null) {
log("updateOverdueState: 数据类型错误,期望 ShelfResult")
return@collect
}
val items = shelfResult.containerGoodsList
if (items.isNullOrEmpty()) {
log("updateOverdueState: 货架列表为空")
return@collect
}
applyOverdueState(items)
}
}
}
}
}
//sendCmd(ACTIVE_CMD)
binding.root.postDelayed({
sendCmd(START_TEMP_CTRL_CMD)
sendCmd(C_TEMP_CMD)
}, 5000)
// TaskManager.startTask()
saveGoodsTask()
overdueTask()
// var androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
//// androidId = "d0e7a2d5f25c775c"
// log("onCreate: androidId=${androidId}")
// if (androidId == "65e1012638f78260" || androidId == "40f89e91ee40611d") {
// androidId = "d0e7a2d5f25c775c"
// }
// App.deviceId = androidId
// //"53babf69-0d2f-3875-a649-f2e12f80ad4c1"
//
// val swDpValue = resources?.configuration?.smallestScreenWidthDp
// log("onCreate: swDpValue=$swDpValue")
// viewModel.getAccessToken(App.deviceId)
viewModel.getShelfList(deviceId = App.deviceId)
// binding.root.postDelayed({
// loadTestData()
// }, 30*1000)
}
private fun loadTestData() {
list.add(ShelfModel(deviceNo = 1, goodsName = "鸡蛋", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 6, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 2, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 7, goodsName = "生姜", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 3, goodsName = "黄瓜", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 8, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 4, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 9, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 5, goodsName = "", deviceId = App.deviceId))
list.add(ShelfModel(deviceNo = 10, goodsName = "", deviceId = App.deviceId))
shelfAdapter.notifyDataSetChanged()
}
private val defDeviceList = listOf(
//原410货柜
"7a991439-3a12-3ef7-809b-c0258b839473",
//原1楼餐厅货柜
"4787e213-90ab-3e32-88e0-ac271a937751"
)
private fun getTokenSuccess(data: RespData<*>) {
log("getTokenSuccess: $data")
data.data?.let {
App.accessToken = it.toString()
//UIUtils.toast(it.toString())
viewModel.getShelfList(deviceId = App.deviceId)
}
}
@@ -232,32 +204,24 @@ class HomeActivity : BaseActivity() {
private fun updateUI(data: RespData<*>) {
binding.include?.root?.gone()
window?.decorView?.postDelayed({ Loading.dismiss() }, 500)
if (data.data !is ShelfResult) {
val shelfResult = data.data as? ShelfResult
if (shelfResult == null) {
log("updateUI: 数据类型错误,期望 ShelfResult,实际类型:${data.data?.javaClass?.simpleName}")
loadEmptyView()
return
}
deviceName = data.data.deviceName
deviceName = shelfResult.deviceName
updateLeftStatus(deviceName)
// updateLeftStatus("$deviceName -°C / -%")
updateLeftStatus("$deviceName")
App.canteenId = data.data.placeId
val tempList = data.data.containerGoodsList
App.canteenId = shelfResult.placeId
var tempList = shelfResult.containerGoodsList
if (tempList.isNullOrEmpty()) {
loadEmptyView()
return
}
// if (tempList.size < 10) {
// toast("数据错误")
// return
// }
list.clear()
val subList01 = tempList.subList(0, tempList.size / 2)
val subList02 = tempList.subList(tempList.size / 2, tempList.size)
repeat(tempList.size / 2) { index ->
list.add(subList01[index].also { it.deviceId = App.deviceId })
list.add(subList02[index].also { it.deviceId = App.deviceId })
}
tempList = GridLayoutTool.initListSort(tempList)
list.addAll(tempList)
shelfAdapter.notifyDataSetChanged()
}
@@ -267,417 +231,121 @@ class HomeActivity : BaseActivity() {
loadEmptyView()
}
/**
* 终端id
*/
private var terminalId: String = ""
/**
* 固件版本
*/
private var terminalVersion: String = ""
private val serialReader by lazy { StringBuilder() }
private fun receiveSerialPortData(srcData: String) {
log("receiveSerialPortData: $srcData")
var data = ""
if (srcData.contains(HEADER) && srcData.contains(FOOTER)) {
serialReader.clear()
val startIndex = srcData.lastIndexOf(HEADER)
var endIndex = srcData.indexOf(FOOTER) + FOOTER.length
if (endIndex < startIndex) {
//类似这种:31314FFFFFD6DADEDB01010CFOOTER在HEADER前
data = srcData.substring(startIndex)
if (data.contains(FOOTER)) {
endIndex = srcData.indexOf(FOOTER) + FOOTER.length
data = data.substring(0, endIndex)
handleReceivedData(data)
serialReader.clear()
return
}
serialReader.clear()
serialReader.append(data).append(",")
return
}
serialReader.append(srcData.substring(startIndex, endIndex))
data = serialReader.toString()
handleReceivedData(data)
serialReader.clear()
return
}
if (srcData.contains(HEADER) && srcData.contains(FOOTER).not()) {
serialReader.clear()
val startIndex = srcData.lastIndexOf(HEADER)
serialReader.append(srcData.substring(startIndex)).append(",")
return
}
if (srcData.contains(HEADER).not() && srcData.contains(FOOTER).not()) {
serialReader.append(srcData).append(",")
return
}
//if (srcData.contains(HEADER).not() && srcData.contains(FOOTER))
val endIndex = srcData.indexOf(FOOTER) + FOOTER.length
serialReader.append(srcData.substring(0, endIndex))
log("receiveSerialPortData:serialReader: $serialReader")
data = serialReader.toString().replace(",", "")
handleReceivedData(data)
}
private fun handleReceivedData(data: String) {
if (data.isEmpty()) {
return
}
val cmd = data.substring(HEADER.length, HEADER.length + 4)
val cmdFlag = cmd.substring(0, 2)
when (cmd) {
"0001" -> {
//终端发起激活指令,拿到固定数据体(终端ID-15字节、固件版本-5字节、命令次数-1字节)
val reqBody =
data.substring(HEADER.length + cmd.length, data.length - FOOTER.length)
if (reqBody.length == 44) {
terminalId = reqBody.substring(2, 32)
terminalVersion = reqBody.substring(32, 42)
log("${LOG_PREFIX}[激活]${cmdFlag}指令:${data},终端id:${terminalId},固件版本:${terminalVersion},发送激活命令:$ACTIVE_CMD")
sendCmd(ACTIVE_CMD)
}
}
"0101", "8101" -> {
//心跳0102${p1}${p2}${version}
//心跳81(终端发起,当终端发送此命令时需要服务端存储串口屏显示信息)
val respCmd = "${HEADER}01020506${terminalVersion}$FOOTER"
log("${LOG_PREFIX}[心跳]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
sendCmd(respCmd)
heartBeat(data)
}
"0202" -> {
//开关锁反馈020200${deviceId}${version}
val respCmd = "${HEADER}020200${terminalId}${terminalVersion}$FOOTER"
log("${LOG_PREFIX}[开锁]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
sendCmd(respCmd)
//lockFeedback(data)
}
"0302" -> {
//获取设备状态
log("${LOG_PREFIX}[获取设备状态]${cmdFlag}指令:${data}")
getDeviceInfo(data)
}
"0501" -> {
//卡号上发
val respCmd = "${HEADER}050203$FOOTER"
log("${LOG_PREFIX}[卡号上发]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
sendCmd(respCmd)
}
"0B01" -> {
//操作记录上传
val respCmd = "${HEADER}0B0203$FOOTER"
log("${LOG_PREFIX}[操作记录上传]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
sendCmd(respCmd)
}
"0C01" -> {
//对时
val timeLong = System.currentTimeMillis() / 1000 + (8 * 3600)
val timeStr = "0000000" + String.format("%X", timeLong)
val time =
timeStr.substring(timeStr.length - 8).chunked(2).reversed().joinToString("")
val respCmd = "${HEADER}0B02${time}$FOOTER"
log("${LOG_PREFIX}[对时]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
sendCmd(respCmd)
}
"0E01" -> {
//终端位置上报
val respCmd = "${HEADER}0E0200$FOOTER"
log("${LOG_PREFIX}[终端位置上报]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
sendCmd(respCmd)
}
"1001" -> {
//温控信息上报
val respCmd = "${HEADER}100200$FOOTER"
val logMsg =
"${LOG_PREFIX}[温控信息上报]${cmdFlag}指令:${data},发送应答指令:${respCmd}"
log(logMsg)
sendCmd(respCmd)
//val tempC = HexUtils.signedHexToDec(data.take(4)) * 0.1
// updateLeftStatus("$deviceName ${"%.1f".format(tempC)}°C / -%")
// updateLeftStatus("$deviceName -°C / -%")
updateLeftStatus("$deviceName")
//D6DADEDB1001015D020000000027001E00020000001E001C00B40000000000010000000100040000001900440003000F00000000000FFFE6000200010002FFD80044000300010005001E0001FFD80056001400280000000000000000008300090000003C00D2000000000000007F003700000000A100000031323136334238423134303233303456322E313100FFFF
runOnUiThread {
if (tipDialog == null) {
CommonDialog(this).apply {
dialogTitle = "温馨提示"
// dialogContent = logMsg
confirmBlock = {
logMsg?.copyTextToClipboard(this@HomeActivity)
}
}
}
if (tipDialog?.isShowing == false) {
tipDialog?.dialogContent = logMsg
tipDialog?.show()
}
}
}
"1002" -> {
log("${LOG_PREFIX}[温控信息应答]1002数据:${data}")
}
"0F02" -> {
log("${LOG_PREFIX}[温控信息应答]0F02数据:${data}")
//D6DADEDB0F020331323136334238423134303233303456322E31313EFFFF00000055010000000000002707000001000000220100000000000091040000120100000100000095140080611400800000000000000000000000000000000000000000010100005031323136334238423134303233303456322E31313EFFFF
runOnUiThread { }
}
else -> {}
}
}
private var tipDialog: CommonDialog? = null
private var fixedDataBody = ""
/** 最后一次成功收到串口数据的时间戳(毫秒) */
private var lastDataReceivedTime = System.currentTimeMillis()
// private fun lockFeedback(data: String) {
// runCatching {
// val fixedDataBodyEndIndex = data.length - FOOTER.length
// val fixedDataBodyStartIndex = HEADER.length + "0202".length + 42
// fixedDataBody = data.substring(fixedDataBodyStartIndex, fixedDataBodyEndIndex)
//
// val lockNo = if (list[shelfIndex].deviceNo in 1..5) "01" else "02"
// val lockOneState = data.substring(HEADER.length + 4, HEADER.length + 6)
// if (lockOneState == "00") {
// //锁1关
// SerialPortManager.send("${HEADER}0202${lockNo}${FOOTER}")
// }
// val lockTwoState = data.substring(HEADER.length + 6, HEADER.length + 8)
// if (lockTwoState == "00") {
// //锁2关
// SerialPortManager.send("${HEADER}0202${lockNo}${FOOTER}")
// }
// }.onFailure {
// it.printStackTrace()
// }
// }
/** 无数据超时提示弹窗 */
private var noDataWarningDialog: CommonDialog? = null
private fun heartBeat(data: String) {
runCatching {
if (data.length < 200) {
return
}
val weightStartIndex = 14
val weightEndIndex = weightStartIndex + 12 * 8
getWeightInfo(
start = weightStartIndex,
end = weightEndIndex,
data = data
)
val temperatureStartIndex = 158
val temperatureEndIndex = temperatureStartIndex + 2
// val temperatureHex = data.substring(temperatureStartIndex, temperatureEndIndex)
// val temperature = hexToDec(temperatureHex)
// val tempC = f2C(temperature.toDouble())
// showTemperatureC = "%.1f".format(tempC)
/**
* 无数据看门狗:每30秒检查一次,若超过5分钟未收到数据则弹出提示。
* 收到数据后会自动重置计时并关闭弹窗。
*/
private val noDataWatchdogTask = object : Runnable {
override fun run() {
// 生命周期检查,防止 Activity 销毁后继续执行
if (isFinishing || isDestroyed) return
//val percent = 10 * list.count { it.goodsName.isNullOrBlank().not() }
// updateLeftStatus("${deviceName} [$temperatureHex] ${showTemperatureC}°C / -%")
getFixDataBody(data, temperatureEndIndex)
}.onFailure {
it.printStackTrace()
}
}
private var deviceName = ""
private fun getWeightInfo(start: Int, end: Int, data: String) {
var index = 1
var count = start
while (count < end) {
val weightInfo = data.substring(count, count + 8)
var weightHex = ""
weightInfo.run {
weightHex =
substring(6, 8) + substring(4, 6) + substring(2, 4) + substring(0, 2)
}
var realWeight = 0
var firstByteBinary = hexToBinary(weightHex.substring(0, 2))
if (firstByteBinary.startsWith("0")) {
//正数
realWeight = weightHex.toInt()
} else {
//负数
firstByteBinary = "0${firstByteBinary.substring(1)}"
val firstByteHex = binaryToHex(firstByteBinary)
weightHex = firstByteHex + weightHex.substring(2)
realWeight = -1 * weightHex.toInt()
}
weightArray.put(index, realWeight)
if (index in 1..10 && list.size == 10) {
list.firstOrNull { it.deviceNo == index }?.let {
it.weight = realWeight.toDouble()
val pos = list.indexOf(it)
shelfAdapter.notifyItemChanged(pos)
log("getWeightInfo: deviceNo=${it.deviceNo},realWeight=${realWeight}")
EventBus.getDefault().post(SendWeightEvent(it.deviceNo, realWeight))
val elapsed = System.currentTimeMillis() - lastDataReceivedTime
if (elapsed >= 5 * 60 * 1000L) {
if (noDataWarningDialog == null) {
noDataWarningDialog = CommonDialog(this@HomeActivity).apply {
dialogTitle = "设备提示"
dialogContent =
"长时间未收到数据,若无法开门或秤重量不更新情况,请考虑断电重启设备"
}
}
if (noDataWarningDialog?.isShowing == false) {
noDataWarningDialog?.show()
}
// val pos = index - 1
// val item = list[pos]
// item.weight = realWeight.toDouble()
// shelfAdapter.notifyItemChanged(pos)
// log("getWeightInfo: deviceNo=${item.deviceNo},realWeight=${realWeight}")
// EventBus.getDefault().post(SendWeightEvent(item.deviceNo, realWeight))
}
count += 8
index++
handler.postDelayed(this, 30 * 1000L)
}
// ShelfActivity.updateWeight(getRealWeight())
// if (list.isNotEmpty()) {
// val intent = Intent(ShelfActivity.RECEIVER_DEVICE_INFO + list[shelfIndex].deviceNo)
// intent.putExtra(ShelfActivity.SHELF_WEIGHT, weightArray[shelfIndex])
// LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
// }
}
private fun getDeviceInfo(data: String) {
log("getDeviceInfo: data=$data")
val weightStartIndex = HEADER.length + 14
val temperatureStartIndex = weightStartIndex + 12 * 8
val temperatureEndIndex = temperatureStartIndex + 2
val temperatureHex = try {
data.substring(temperatureStartIndex, temperatureEndIndex)
} catch (e: Exception) {
e.printStackTrace()
return
}
// val temperature = hexToDec(temperatureHex)
// val tempC = f2C(temperature.toDouble())
// showTemperatureC = "%.1f".format(tempC)
//val percent = 10 * list.count { it.goodsName.isNullOrBlank().not() }
// updateLeftStatus("${deviceName} ${showTemperatureC}°C / -%")
// val tempC = HexUtils.signedHexToDec(temperatureHex) * 0.1
// updateLeftStatus("$deviceName -°C / -%")
updateLeftStatus("$deviceName")
getWeightInfo(
start = weightStartIndex,
end = temperatureStartIndex,
data = data
)
getFixDataBody(data, temperatureEndIndex)
}
fun getFixDataBody(data: String, temperatureEndIndex: Int) {
try {
if (terminalId.isNotBlank() && terminalVersion.isNotBlank()) {
return
}
val fixDataBodyStartIndex = temperatureEndIndex + 1
val fixDataBody = data.substring(fixDataBodyStartIndex, data.length - FOOTER.length)
if (fixDataBody.length == 42) {
terminalId = fixDataBody.substring(0, 30)
terminalVersion = fixDataBody.substring(30, 40)
log("receiveSerialPortData:从设备状态获取数据,终端id:${terminalId},固件版本:${terminalVersion}")
}
} catch (e: Exception) {
e.printStackTrace()
}
/**
* 用户超时无操作时关灯
*/
override fun onUserInactive() {
switchLight(false)
}
override fun onDestroy() {
lifecycleScope.launch {
SerialPortManager.close()
}
// TaskManager.cancelTask()
// 清理 Handler 所有回调,防止内存泄漏
handler.removeCallbacksAndMessages(null)
// 关闭并释放 Dialog 引用,防止内存泄漏
tipDialog?.dismiss()
tipDialog = null
noDataWarningDialog?.dismiss()
noDataWarningDialog = null
runBlocking { ScaleManager.release() }
saveTaskJob?.cancel()
overdueTaskJob?.cancel()
EventBus.getDefault().unregister(this)
super.onDestroy()
}
@Suppress("DEPRECATION")
private val shelfAdapter by lazy {
ShelfAdapter(list).apply {
setOnItemClickListener { _, _, position ->
shelfIndex = position
lifecycleScope.launch {
if (isNeedOpenLock) {
val openCmd = if (list[position].deviceNo in 1..5)
LEFT_SHELF_OPEN_CMD
else
RIGHT_SHELF_OPEN_CMD
val openState = SerialPortManager.send(openCmd)
}
// if (openState.not()) {
// toast("${list[position].goodsName}柜门开启失败")
// return@launch
// }
//SerialPortManager.send(START_CMD)
launch(Intent(this@HomeActivity, ShelfActivity::class.java).also {
it.putExtra(ShelfActivity.SHELF_MODEL, list[position])
}) {
it?.run {
val model =
getSerializableExtra(ShelfActivity.SHELF_MODEL) as ShelfModel
list[position].apply {
goodsId = model.goodsId
goodsName = model.goodsName
weight = model.weight
if (model.putCabinetDay.isNullOrBlank().not()) {
putCabinetDay = model.putCabinetDay
}
}
notifyItemChanged(position)
try {
val submitList = list.sortedBy { item -> item.deviceNo }
val body = ShelfBody().also { body ->
body.deviceId = App.deviceId ?: ""
body.canteenId = App.canteenId
body.temperature = showTemperatureC
body.humidity = showHumidity
body.goodsList = submitList
}
Log.d("mzf", "performSync: body:${body.toJsonString()}")
lifecycleScope.launch {
val resp = apiService.saveShelfGoodsList(body = body)
Log.d("mzf", "performSync: resp:${resp.toJsonString()}")
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
onShelfItemClicked(position)
}
}
}
private fun initRecyclerView() {
binding.rvShelf.run {
layoutManager =
GridLayoutManager(this@HomeActivity, 2, GridLayoutManager.VERTICAL, false)
adapter = shelfAdapter
/**
* 处理货架格子点击事件:按需开锁,然后跳转 ShelfActivity
*/
private fun onShelfItemClicked(position: Int) {
shelfIndex = position
lifecycleScope.launch {
if (isNeedOpenLock()) {
val openCmd = if (list[position].deviceNo in 1..5)
ScaleManager.buildOpenLock1Cmd()
else
ScaleManager.buildOpenLock2Cmd()
ScaleManager.sendCmd(openCmd)
}
launch(Intent(this@HomeActivity, ShelfActivity::class.java).also {
it.putExtra(ShelfActivity.SHELF_MODEL, list[position])
}) { result ->
result?.let { onShelfActivityResult(position, it) }
}
}
}
itemAnimator.let {
/**
* 处理 ShelfActivity 返回结果:更新本地列表数据并同步到服务端
*/
private fun onShelfActivityResult(position: Int, intent: Intent) {
@Suppress("DEPRECATION")
val model = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getSerializableExtra(ShelfActivity.SHELF_MODEL, ShelfModel::class.java)
} else {
intent.getSerializableExtra(ShelfActivity.SHELF_MODEL) as? ShelfModel
} ?: return
list[position].apply {
goodsId = model.goodsId
goodsName = model.goodsName
weight = model.weight
if (model.putCabinetDay.isNullOrBlank().not()) {
putCabinetDay = model.putCabinetDay
}
}
shelfAdapter.notifyItemChanged(position)
syncShelfGoodsToServer()
}
private fun initRecyclerView() {
val layoutManager = GridLayoutTool.getLayoutManager(this@HomeActivity) ?: return
binding.rvShelf.let {
it.layoutManager = layoutManager
it.adapter = shelfAdapter
it.itemAnimator.let {
if (it is DefaultItemAnimator) {
it.supportsChangeAnimations = false
}
@@ -685,42 +353,28 @@ class HomeActivity : BaseActivity() {
}
}
private fun updateDateTime() {
val sdf = SimpleDateFormat(MM_DD_EEEE__HH_MM_SS, Locale.CHINA)
val dateTime = sdf.format(Date())
updateRightStatus(dateTime)
}
private val handler = Handler(Looper.getMainLooper())
private val updateTask = object : Runnable {
override fun run() {
updateDateTime()
handler.postDelayed(this, 1000)
}
}
override fun onResume() {
super.onResume()
hideStatusBar()
handler.post(updateTask)
// D6DADEDB00010031323136334238423134303233303456322E313100FFFF
// sendCmd("${HEADER}00010031323136334238423134303233303456322E313152${FOOTER}")
// 启动无数据看门狗(每30秒检查一次)
handler.postDelayed(noDataWatchdogTask, 30 * 1000L)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(updateTask)
// 停止无数据看门狗
handler.removeCallbacks(noDataWatchdogTask)
}
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
list.clear()
shelfAdapter.notifyDataSetChanged()
binding.include?.let {
it.root.visible()
it.root.setOnClickListener {
//viewModel.getAccessToken(App.deviceId)
viewModel.getShelfList(deviceId = App.deviceId)
}
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_white)
@@ -729,12 +383,7 @@ class HomeActivity : BaseActivity() {
}
@Subscribe(threadMode = ThreadMode.MAIN)
public fun weightEvent(event: WeightEvent) {
}
@Subscribe(threadMode = ThreadMode.MAIN)
public fun clearEmptyShelf(event: ClearShelfEvent) {
fun clearEmptyShelf(event: ClearShelfEvent) {
val item = list.firstOrNull { it.deviceNo == event.shelfNo }
item?.apply {
goodsId = ""
@@ -748,64 +397,67 @@ class HomeActivity : BaseActivity() {
}
private val taskExecutor by lazy { IntervalExecutor() }
private var saveTaskJob: Job?=null
private var saveTaskJob: Job? = null
/**
* 保存定时任务
*/
private fun saveGoodsTask() {
saveTaskJob = taskExecutor.startIntervalTaskWithInitialDelay(1 * 60 * 1000L, 5 * 60 * 1000L) {
saveShelfGoodsList()
}
saveTaskJob =
taskExecutor.startIntervalTaskWithInitialDelay(1 * 60 * 1000L, 5 * 60 * 1000L) {
syncShelfGoodsToServer()
}
}
private var overdueTaskJob: Job?=null
private var overdueTaskJob: Job? = null
/**
* 过期时间定时任务
*/
private fun overdueTask() {
overdueTaskJob = taskExecutor.startIntervalTaskWithInitialDelay(5 * 1000L, 5 * 60* 1000L) {
overdueTaskJob = taskExecutor.startIntervalTaskWithInitialDelay(5 * 1000L, 5 * 60 * 1000L) {
updateOverdueState()
}
}
private fun updateOverdueState() {
viewModel.updateOverdueState { state, items ->
if (items.isNullOrEmpty()) return@updateOverdueState
list.forEachIndexed { index, model ->
//接口过期状态变化后更新状态
if (items[index].overdue != model.overdue) {
model.overdue = items[index].overdue
shelfAdapter.notifyItemChanged(index)
log("更新编号为${model.deviceNo}物品:${model.goodsName}的过期状态:${model.overdue}")
}
viewModel.updateOverdueState()
}
/**
* 将接口返回的过期状态应用到本地列表,仅在状态变化时刷新对应 item
*/
private fun applyOverdueState(items: List<ShelfModel>) {
if (items.size != list.size) return
list.forEachIndexed { index, model ->
// 接口过期状态变化后更新状态
if (items[index].overdue != model.overdue) {
model.overdue = items[index].overdue
shelfAdapter.notifyItemChanged(index)
log("更新编号为${model.deviceNo}物品:${model.goodsName}的过期状态:${model.overdue}")
}
}
}
private fun saveShelfGoodsList() {
log("performSync: list:${list.toJsonString()}")
val submitList = list.sortedBy { it.deviceNo }
/**
* 构建 ShelfBody 并提交到服务端(被定时任务与 ShelfActivity 返回共用)
*/
private fun syncShelfGoodsToServer() {
log("performSync显示list:${list.toJsonString()}")
val submitList = GridLayoutTool.submitListSort(list)
submitList.forEach {
if (it.goodsId.isNullOrBlank()) {
it.weight = it.weightBak
}
}
// submitList.forEach {
// it.temperature = HomeActivity.showTemperatureC
// it.humidity = HomeActivity.showHumidity
// }
val body = ShelfBody().also {
it.deviceId = App.deviceId
it.deviceId = App.deviceId ?: ""
it.canteenId = App.canteenId
it.temperature = showTemperatureC
it.humidity = showHumidity
it.goodsList = submitList
}
log("performSync: body:${body.toJsonString()}")
log("performSync提交body:${body.toJsonString()}")
viewModel.saveShelfGoodsList(body)
}
}
}
@@ -0,0 +1,465 @@
package com.shuwei.intelligent.shelves.activity
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.GridLayoutManager
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.adapter.ShelfV2Adapter
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityHomeBinding
import com.shuwei.intelligent.shelves.dialog.CommonDialog
import com.shuwei.intelligent.shelves.model.CabinetInitResult
import com.shuwei.intelligent.shelves.model.ClearShelfEvent
import com.shuwei.intelligent.shelves.model.ShelfModelV2
import com.shuwei.intelligent.shelves.model.SendWeightEvent
import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.model.SyncBody
import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.net.NetViewModel
import com.shuwei.intelligent.shelves.net.NetViewModelV2
import com.shuwei.intelligent.shelves.net.RespData
import com.shuwei.intelligent.shelves.net.UiState
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.C_TEMP_CMD
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.START_TEMP_CTRL_CMD
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.google.android.flexbox.FlexDirection
import com.google.android.flexbox.FlexWrap
import com.google.android.flexbox.FlexboxLayoutManager
import com.shuwei.intelligent.shelves.utils.IntervalExecutor
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toJsonString
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class HomeV2Activity : BaseActivity() {
companion object {}
override val enableRightStatusMenu: Boolean = true
// 货架列表数据
private val list: MutableList<ShelfModelV2> = mutableListOf()
// 当前选中货架下标
private var shelfIndex = 0
// 当前温度(摄氏)
private var showTemperatureC = "0"
// 当前湿度
private var showHumidity = "0"
// 仅特定 deviceId 的部署点需要发送开锁指令
private fun isNeedOpenLock() = App.deviceId == "4787e213-90ab-3e32-88e0-ac271a937751"
private var deviceName = ""
private lateinit var binding: ActivityHomeBinding
private val viewModel: NetViewModel by viewModels()
private val viewModelV2: NetViewModelV2 by viewModels()
// 串口协议解析器,通过回调通知本 Activity
private fun initScaleManager() {
lifecycleScope.launch {
val ok = ScaleManager
.init(activateCode = ProtocolConstants.ACTIVE_MAP[App.deviceId] ?: "")
.onHeartbeat { _, data ->
data.weights.forEach { weight ->
val shelfNo = weight.index
// if (shelfNo in 1..list.size) {
// 用 slotNo 末尾数字与串口编号匹配
val pos = list.indexOfFirst {
it.slotNo?.filter { c -> c.isDigit() }?.toIntOrNull() == shelfNo
}
val model = list.getOrNull(pos)?:return@forEach
model.weight = (1.0 * weight.grams / 1000.0).toBigDecimal()
// if (model.itemType == 1) {
// model.actualQty = (weight.grams / 1000.0).toBigDecimal()
// } else {
// model.totalWeightG = weight.grams.toBigDecimal()
// }
shelfAdapter.notifyItemChanged(pos)
log("getWeightInfo: slotNo=${model.slotNo}, realWeight=${weight.grams}, temp=${data.temperature}, item:${model.toJsonString()}")
EventBus.getDefault().post(SendWeightEvent(shelfNo, weight.grams))
// }
}
}
.onTempReport { frame ->
log("onTempReport: $frame")
updateLeftStatus(deviceName)
}
.onLog { message -> log(message) }
.start { data ->
lastDataReceivedTime = System.currentTimeMillis()
noDataWarningDialog?.takeIf { it.isShowing }?.dismiss()
log("receiveSerialPortData: $data")
}
log("onCreate: openState=$ok")
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityHomeBinding.inflate(layoutInflater)
EventBus.getDefault().register(this)
setBackground()
setContentView(binding.root)
onBackPressedDispatcher.addCallback(this) {
}
updateLeftStatus("")
// 初始化 ScaleManager:注册回调、打开串口、启动接收
initScaleManager()
// 网络状态监听,独立协程,与串口初始化并行执行
lifecycleScope.launch {
viewModelV2.initCabinetUiState.collect { state ->
when (state) {
is UiState.Loading -> showProgress()
is UiState.Success<*> -> updateUI(state.data)
is UiState.Error -> showError(state.msg)
else -> {}
}
}
}
binding.root.postDelayed({
sendCmd(START_TEMP_CTRL_CMD)
sendCmd(C_TEMP_CMD)
}, 5000)
saveGoodsTask()
overdueTask()
viewModelV2.initCabinet(cabinetId = App.deviceId)
}
private val defDeviceList = listOf(
//原410货柜
"7a991439-3a12-3ef7-809b-c0258b839473",
//原1楼餐厅货柜
"4787e213-90ab-3e32-88e0-ac271a937751"
)
private fun getTokenSuccess(data: RespData<*>) {
log("getTokenSuccess: $data")
data.data?.let {
App.accessToken = it.toString()
viewModelV2.initCabinet(cabinetId = App.deviceId)
}
}
private fun showProgress() {
Loading.show(this)
}
private var cabinetInitResult: CabinetInitResult? = null
@SuppressLint("NotifyDataSetChanged")
private fun updateUI(data: RespData<*>) {
binding.include.root.gone()
window?.decorView?.postDelayed({ Loading.dismiss() }, 500)
cabinetInitResult = data.data as? CabinetInitResult
if (cabinetInitResult == null) {
log("updateUI: 数据类型错误,期望 CabinetInitResult,实际类型:${data.data?.javaClass?.simpleName}")
loadEmptyView()
return
}
val cabinet = cabinetInitResult!!
//行列数量后端返回是反着的
val rows = cabinet.horizontalRows
val columns = cabinet.verticalCount
log("updateUI: rows=$rows, columns=$columns")
deviceName = cabinet.deviceName
updateLeftStatus(deviceName)
App.canteenId = cabinet.canteenId.toString()
var tempList = cabinet.containerGoodsList
if (tempList.isNullOrEmpty()) {
loadEmptyView()
return
}
list.clear()
list.addAll(tempList)
val displayMetrics = resources.displayMetrics
shelfAdapter.itemWidth = (displayMetrics.widthPixels - 24.dp) / columns - 24.dp
shelfAdapter.itemHeight = (displayMetrics.heightPixels - 68.dp) / rows - 24.dp
if (binding.rvShelf.adapter == null) {
initRecyclerView()
} else {
shelfAdapter.notifyDataSetChanged()
}
}
private fun showError(message: String) {
Loading.dismiss()
toast(message)
loadEmptyView()
}
private var tipDialog: CommonDialog? = null
/** 最后一次成功收到串口数据的时间戳(毫秒) */
private var lastDataReceivedTime = System.currentTimeMillis()
/** 无数据超时提示弹窗 */
private var noDataWarningDialog: CommonDialog? = null
/**
* 无数据看门狗:每30秒检查一次,若超过5分钟未收到数据则弹出提示。
* 收到数据后会自动重置计时并关闭弹窗。
*/
private val noDataWatchdogTask = object : Runnable {
override fun run() {
// 生命周期检查,防止 Activity 销毁后继续执行
if (isFinishing || isDestroyed) return
val elapsed = System.currentTimeMillis() - lastDataReceivedTime
if (elapsed >= 5 * 60 * 1000L) {
if (noDataWarningDialog == null) {
noDataWarningDialog = CommonDialog(this@HomeV2Activity).apply {
dialogTitle = "设备提示"
dialogContent =
"长时间未收到数据,若无法开门或秤重量不更新情况,请考虑断电重启设备"
}
}
if (noDataWarningDialog?.isShowing == false) {
noDataWarningDialog?.show()
}
}
handler.postDelayed(this, 30 * 1000L)
}
}
/**
* 用户超时无操作时关灯
*/
override fun onUserInactive() {
switchLight(false)
}
override fun onDestroy() {
// 清理 Handler 所有回调,防止内存泄漏
handler.removeCallbacksAndMessages(null)
// 关闭并释放 Dialog 引用,防止内存泄漏
tipDialog?.dismiss()
tipDialog = null
noDataWarningDialog?.dismiss()
noDataWarningDialog = null
runBlocking { ScaleManager.release() }
saveTaskJob?.cancel()
overdueTaskJob?.cancel()
EventBus.getDefault().unregister(this)
super.onDestroy()
}
private val shelfAdapter by lazy {
ShelfV2Adapter(list).apply {
setOnItemClickListener { _, _, position ->
onShelfItemClicked(position)
}
}
}
/**
* 处理货架格子点击事件:按需开锁,然后跳转 ShelfV2Activity
*/
private fun onShelfItemClicked(position: Int) {
shelfIndex = position
lifecycleScope.launch {
if (isNeedOpenLock()) {
// 解析 slotNo 末尾数字判断锁区(1-5 号格口走锁1,其余走锁2)
val slotNum = list[position].slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: 0
val openCmd = if (slotNum in 1..5)
ScaleManager.buildOpenLock1Cmd()
else
ScaleManager.buildOpenLock2Cmd()
ScaleManager.sendCmd(openCmd)
}
launch(Intent(this@HomeV2Activity, ShelfV2Activity::class.java).also {
it.putExtra(ShelfV2Activity.SHELF_MODEL, list[position])
}) { result ->
result?.let { onShelfActivityResult(position, it) }
}
}
}
/**
* 处理 ShelfV2Activity 返回结果
* TODO: 待 ShelfV2Activity 迁移到 RecordItem 后重新实现字段更新逻辑
*/
private fun onShelfActivityResult(position: Int, intent: Intent) {
@Suppress("DEPRECATION")
val model = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getSerializableExtra(ShelfV2Activity.SHELF_MODEL, ShelfModelV2::class.java)
} else {
intent.getSerializableExtra(ShelfV2Activity.SHELF_MODEL) as? ShelfModelV2
} ?: return
list[position].let {
it.itemType = model.itemType
it.itemName = model.itemName
it.traceCode = model.traceCode
it.cleanOrderNo = model.cleanOrderNo
it.spec = model.spec
it.foodId = model.foodId
it.storeTime = model.storeTime
it.slotStatus = model.slotStatus
}
shelfAdapter.notifyItemChanged(position)
// syncShelfGoodsToServer()
}
private fun initRecyclerView() {
val layoutManager = FlexboxLayoutManager(this).apply {
flexDirection = FlexDirection.COLUMN
flexWrap = FlexWrap.WRAP
}
binding.rvShelf.let {
it.layoutManager = layoutManager
it.adapter = shelfAdapter
it.itemAnimator.let { animator ->
if (animator is DefaultItemAnimator) {
animator.supportsChangeAnimations = false
}
}
}
}
private val handler = Handler(Looper.getMainLooper())
override fun onResume() {
super.onResume()
hideStatusBar()
// 启动无数据看门狗(每30秒检查一次)
handler.postDelayed(noDataWatchdogTask, 30 * 1000L)
}
override fun onPause() {
super.onPause()
// 停止无数据看门狗
handler.removeCallbacks(noDataWatchdogTask)
}
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
list.clear()
shelfAdapter.notifyDataSetChanged()
binding.include?.let {
it.root.visible()
it.root.setOnClickListener {
viewModelV2.initCabinet(cabinetId = App.deviceId)
}
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_white)
it.tvEmptyContent.setTextColor(Color.WHITE)
}
// loadTestData()
}
// @SuppressLint("NotifyDataSetChanged")
// private fun loadTestData() {
// binding.rvShelf.layoutManager = GridLayoutManager(this, 2)
// binding.rvShelf.adapter = shelfAdapter
// binding.include.root.gone()
// list.clear()
// var tempList = (1..10).map { i -> ShelfModelV2(slotNo = i.toString()) }
// tempList = GridLayoutTool.initListSortV2(tempList)
// list.addAll(tempList)
// shelfAdapter.notifyDataSetChanged()
// }
@Subscribe(threadMode = ThreadMode.MAIN)
fun clearEmptyShelf(event: ClearShelfEvent) {
// 用 slotNo 末尾数字与串口编号匹配
val index = list.indexOfFirst {
it.slotNo?.filter { c -> c.isDigit() }?.toIntOrNull() == event.shelfNo
}
if (index < 0) return
list[index] = list[index].copy(itemName = "")
shelfAdapter.notifyItemChanged(index)
}
private val taskExecutor by lazy { IntervalExecutor() }
private var saveTaskJob: Job? = null
/**
* 保存定时任务
*/
private fun saveGoodsTask() {
saveTaskJob =
// taskExecutor.startIntervalTaskWithInitialDelay(1 * 60 * 1000L, 5 * 60 * 1000L) {
taskExecutor.startIntervalTaskWithInitialDelay(10 * 1000L, 2 * 60 * 1000L) {
syncShelfGoodsToServer()
}
}
private var overdueTaskJob: Job? = null
/**
* 过期时间定时任务
*/
private fun overdueTask() {
overdueTaskJob = taskExecutor.startIntervalTaskWithInitialDelay(5 * 1000L, 5 * 60 * 1000L) {
viewModelV2.initCabinet(cabinetId = App.deviceId)
}
}
/**
* 将接口返回的临期状态应用到本地列表,仅在状态变化时刷新对应 item
*/
private fun applyOverdueState(items: List<ShelfModelV2>) {
if (items.size != list.size) return
items.forEachIndexed { index, newItem ->
val oldItem = list[index]
// slotStatus=2 表示临期预警
if (oldItem.slotStatus != newItem.slotStatus) {
list[index] = newItem
shelfAdapter.notifyItemChanged(index)
log("更新格口 ${newItem.slotNo} 物品:${newItem.itemName} 的状态:${newItem.slotStatus}")
}
}
}
/**
* 构建同步请求并提交到服务端
*/
private fun syncShelfGoodsToServer() {
if (cabinetInitResult == null) {
return
}
val submitList = list.sortedBy { it.slotNo?.toIntOrNull() ?: 0 }
viewModelV2.syncCabinetData(SyncBody().apply {
// cabinetId = list[0].cabinetId
cabinetId = App.deviceId
temperature = null
humidity = null
goodsList = submitList
})
}
}
@@ -16,6 +16,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.GlobalData
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityInitBinding
import com.shuwei.intelligent.shelves.model.DeviceConfigInfo
@@ -58,12 +59,16 @@ class InitActivity : BaseActivity() {
binding = ActivityInitBinding.inflate(layoutInflater)
setContentView(binding.root)
App.deviceId = AppUtil.getUDID( this)
// App.deviceId = "4787e213-90ab-3e32-88e0-ac271a937751"
Log.d(TAG, "onCreate: deviceId = ${App.deviceId}")
SpTool.put(SpTool.DEVICE_ID, App.deviceId)
App.configUrl = UrlConfig.BASE_URL
val appBaseUrl = SpTool.baseUrl
if (!appBaseUrl.isBlank()) {
GlobalData.appBaseUrl = appBaseUrl
} else {
GlobalData.appBaseUrl = GlobalData.PROD_BASE_URL
}
App.canteenId = "0"
// var deviceId = AppUtil.getUDID(this)
@@ -106,60 +111,60 @@ class InitActivity : BaseActivity() {
}
}
private fun initConfig() {
binding.ivQrCode.setImageBitmap(
QRCodeUtil.generateQRCode(
content = App.deviceId,
size = 200.dp
)
)
binding.btnInit.setOnClickListener {
viewModel.getDeviceToken(App.deviceId)
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
launch {
viewModel.getDeviceTokenUiState.collect { state ->
when (state) {
is UiState.Loading -> {}
is UiState.Success<*> -> {
state.data.data?.let { deviceToken ->
Log.d(TAG, "initConfig: $deviceToken")
viewModel.getDeviceConfig(
deviceId = App.deviceId,
deviceToken = deviceToken.toString()
)
}
}
is UiState.Error -> toast(state.msg)
else -> {}
}
}
}
launch {
viewModel.getDeviceConfigUiState.collect { state ->
when (state) {
is UiState.Loading -> {}
is UiState.Success<*> -> {
state.data.data?.let {
if (it is DeviceConfigInfo) {
SpTool.put(SpTool.DEVICE_CONFIG_CACHE, it.toJsonString())
App.configUrl = it.appPackageUrl?:""
App.canteenId = it.canteenId?:""
startActivity<HomeActivity>()
finish()
}
}
}
is UiState.Error -> toast(message = state.msg)
else -> {}
}
}
}
}
}
}
// private fun initConfig() {
// binding.ivQrCode.setImageBitmap(
// QRCodeUtil.generateQRCode(
// content = App.deviceId,
// size = 200.dp
// )
// )
//// binding.btnInit.setOnClickListener {
//// viewModel.getDeviceToken(App.deviceId)
//// }
// lifecycleScope.launch {
// repeatOnLifecycle(Lifecycle.State.CREATED) {
//// launch {
//// viewModel.getDeviceTokenUiState.collect { state ->
//// when (state) {
//// is UiState.Loading -> {}
//// is UiState.Success<*> -> {
//// state.data.data?.let { deviceToken ->
//// Log.d(TAG, "initConfig: $deviceToken")
//// viewModel.getDeviceConfig(
//// deviceId = App.deviceId,
//// deviceToken = deviceToken.toString()
//// )
//// }
//// }
//// is UiState.Error -> toast(state.msg)
//// else -> {}
//// }
//// }
//// }
//// launch {
//// viewModel.getDeviceConfigUiState.collect { state ->
//// when (state) {
//// is UiState.Loading -> {}
//// is UiState.Success<*> -> {
//// state.data.data?.let {
//// if (it is DeviceConfigInfo) {
//// SpTool.put(SpTool.DEVICE_CONFIG_CACHE, it.toJsonString())
//// GlobalData.appBaseUrl = it.appPackageUrl?:""
//// App.canteenId = it.canteenId?:""
////
//// startActivity<HomeActivity>()
//// finish()
//// }
//// }
//// }
//// is UiState.Error -> toast(message = state.msg)
//// else -> {}
//// }
//// }
//// }
// }
// }
// }
private fun checkConfigData(data: String): Boolean {
if (data.isBlank()) {
@@ -169,7 +174,7 @@ class InitActivity : BaseActivity() {
if (config == null) {
return false
}
App.configUrl = config.appPackageUrl?:""
GlobalData.appBaseUrl = config.appPackageUrl?:""
App.canteenId = config.canteenId?:""
return true
}
@@ -279,7 +284,8 @@ class InitActivity : BaseActivity() {
* 跳转到 HomeActivity
*/
private fun navigateToHome() {
startActivity<HomeActivity>()
// startActivity<HomeActivity>()
startActivity<HomeV2Activity>()
finish()
}
@@ -1,28 +1,53 @@
package com.shuwei.intelligent.shelves.activity
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.widget.TextView
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.shuwei.intelligent.shelves.adapter.LogAdapter
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityLogListBinding
import com.shuwei.intelligent.shelves.dialog.CommonDialog
import com.shuwei.intelligent.shelves.utils.ext.copyTextToClipboard
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.startActivity
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
import com.shuwei.intelligent.shelves.utils.ext.visible
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class LogActivity : BaseActivity() {
companion object {
const val IS_LOG_DIR = "isLogDir"
const val LOG_DATE = "logDate"
const val YYYY_MM_DD__EEEE_HH_MM_SS = "yyyy年MM月dd日 EEEE***HH:mm:ss"
}
/**
* 覆写时钟回调:左侧状态栏显示年月日星期,右侧状态栏显示时分秒
*/
override fun onClockTick() {
val sdf = SimpleDateFormat(YYYY_MM_DD__EEEE_HH_MM_SS, Locale.CHINA)
val arr = sdf.format(Date()).split("***")
updateLeftStatus(arr[0])
updateRightStatus(arr[1])
}
private lateinit var binding:ActivityLogListBinding
private var isLogDir = false
private var logDate = ""
private val logList = mutableListOf<String>()
private val originalLogList = mutableListOf<String>() // 原始日志备份
private val logAdapter by lazy {
LogAdapter(logList).apply {
setOnItemClickListener { _, _, position ->
@@ -62,24 +87,20 @@ class LogActivity : BaseActivity() {
logAdapter.notifyDataSetChanged()
}
if (isLogDir) {
logList.addAll(fileLogger.getLogFiles())
} else {
logList.addAll(fileLogger.readLogsByName(logDate))
}
binding.rvLogList.let {
it.layoutManager = LinearLayoutManager(this)
it.adapter = logAdapter
}
setupSearch()
setupJumpButton()
binding.btnFind.run {
if (isLogDir) gone() else visible()
}
binding.btnFind.setOnClickListener {
if (isLogDir) {
return@setOnClickListener
}
val list = logList.filter { it.contains("${HomeActivity.HEADER}1001") }
if (isLogDir) return@setOnClickListener
val list = logList.filter { it.contains("${ProtocolConstants.HEADER}${ProtocolConstants.CMD_TEMP_REPORT}") }
if (list.isEmpty()) {
toast("未找到到1001指令")
return@setOnClickListener
@@ -88,6 +109,95 @@ class LogActivity : BaseActivity() {
logList.addAll(list)
logAdapter.notifyDataSetChanged()
}
// 后台加载日志数据
lifecycleScope.launch {
binding.progressBar.visible()
val data = withContext(Dispatchers.IO) {
if (isLogDir) fileLogger.getLogFiles()
else fileLogger.readLogsByName(logDate)
}
logList.addAll(data)
originalLogList.addAll(data)
logAdapter.notifyDataSetChanged()
binding.progressBar.gone()
setupTimeNavigation()
}
}
// 搜索功能
private fun setupSearch() {
binding.etSearch.addTextChangedListener(afterTextChanged = { s ->
val keyword = s?.toString() ?: ""
logList.clear()
logList.addAll(if (keyword.isEmpty()) originalLogList else originalLogList.filter { it.contains(keyword, ignoreCase = true) })
logAdapter.notifyDataSetChanged()
})
}
// 快速跳转功能
private fun setupJumpButton() {
var isAtTop = true
binding.fabJump.setOnClickListener {
if (isAtTop) {
binding.rvLogList.scrollToPosition(logList.size - 1)
} else {
binding.rvLogList.scrollToPosition(0)
}
isAtTop = !isAtTop
}
}
// 时间导航功能
private fun setupTimeNavigation() {
if (isLogDir) {
binding.hsvTimeNav.gone()
return
}
val timeMap = mutableMapOf<String, Int>()
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
originalLogList.forEachIndexed { index, log ->
val timeStr = log.substringAfter("[").substringBefore("]")
try {
val date = dateFormat.parse(timeStr)
date?.let {
val hourKey = SimpleDateFormat("HH:00", Locale.getDefault()).format(it)
if (!timeMap.containsKey(hourKey)) {
timeMap[hourKey] = index
}
}
} catch (e: Exception) {
// 忽略解析失败的日志
}
}
if (timeMap.isEmpty()) {
binding.hsvTimeNav.gone()
return
}
timeMap.toSortedMap().forEach { (hour, position) ->
val btn = TextView(this).apply {
text = hour
textSize = 14f
setTextColor(Color.BLACK)
setPadding(12.dp, 8.dp, 12.dp, 8.dp)
gravity = Gravity.CENTER
setBackgroundColor(Color.parseColor("#E0E0E0"))
setOnClickListener {
binding.rvLogList.scrollToPosition(position)
}
}
val params = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
android.widget.LinearLayout.LayoutParams.MATCH_PARENT
).apply {
marginEnd = 8.dp
}
binding.llTimeNav.addView(btn, params)
}
}
}
@@ -3,8 +3,6 @@ package com.shuwei.intelligent.shelves.activity
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.LinearLayout
import androidx.activity.viewModels
@@ -18,7 +16,7 @@ import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.scwang.smart.refresh.layout.constant.RefreshState
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.activity.HomeActivity.Companion.DEVICE_INFO_CMD
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.adapter.SearchAdapter
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityShelfBinding
@@ -148,7 +146,7 @@ class ShelfActivity : BaseActivity() {
//binding.tvFoodWeight.text = "0克"
EventBus.getDefault().post(ClearShelfEvent(shelfModel!!.deviceNo, tempWeight))
}
sendCmd(DEVICE_INFO_CMD)
sendCmd(ScaleManager.buildGetStatusCmd())
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.getGoodsListUiState.collect { state ->
@@ -305,31 +303,23 @@ class ShelfActivity : BaseActivity() {
}
}
private fun updateDateTime() {
/**
* 覆写时钟回调:左侧状态栏显示年月日星期,右侧状态栏显示时分秒
*/
override fun onClockTick() {
val sdf = SimpleDateFormat(YYYY_MM_DD__EEEE_HH_MM_SS, Locale.CHINA)
val dateTime = sdf.format(Date())
val arr = dateTime.split("***")
val arr = sdf.format(Date()).split("***")
updateLeftStatus(arr[0])
updateRightStatus(arr[1])
}
private val handler = Handler(Looper.getMainLooper())
private val updateTask = object : Runnable {
override fun run() {
updateDateTime()
handler.postDelayed(this, 1000)
}
}
override fun onResume() {
super.onResume()
hideStatusBar()
handler.post(updateTask)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(updateTask)
}
@SuppressLint("NotifyDataSetChanged")
@@ -369,22 +359,20 @@ class ShelfActivity : BaseActivity() {
private fun clearZero() {
lifecycleScope.launch {
val shelfNo = shelfModel?.deviceNo ?: 0
val hex = shelfNo.toString(16).padStart(2, '0').uppercase()
Log.d(TAG, "onCreate: hex=$hex")
val zeroClearingCmd = "${HomeActivity.HEADER}0401${hex}${HomeActivity.FOOTER}"
val zeroClearingCmd = ScaleManager.buildScaleZeroCmd(shelfNo)
log("编号${shelfNo}货架执行清零指令:${zeroClearingCmd}")
sendCmd(zeroClearingCmd)
sendCmd(
if (shelfNo in 1..5) HomeActivity.LEFT_SHELF_OPEN_CMD else HomeActivity.RIGHT_SHELF_OPEN_CMD
)
// sendCmd(
// if (shelfNo in 1..5) ProtocolConstants.LEFT_SHELF_OPEN_CMD else ProtocolConstants.RIGHT_SHELF_OPEN_CMD
// )
//清零
realWeight = 0
// binding.tvFoodWeight.text = "${realWeight}克"
sendCmd(DEVICE_INFO_CMD)
sendCmd(ScaleManager.buildGetStatusCmd())
startTime = System.currentTimeMillis()
Loading.show(this@ShelfActivity)
// window.decorView.postDelayed({ Loading.dismiss() }, 5000)
window.decorView.postDelayed({ Loading.dismiss() }, 10000)
}
KeyboardUtil.hideKeyboard(window.decorView)
}
@@ -0,0 +1,384 @@
package com.shuwei.intelligent.shelves.activity
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.widget.LinearLayout
import androidx.activity.viewModels
import androidx.core.view.updateLayoutParams
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.scwang.smart.refresh.layout.constant.RefreshState
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.adapter.CleanPackageAdapter
import com.shuwei.intelligent.shelves.adapter.MealPackageAdapter
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityShelfV2Binding
import com.shuwei.intelligent.shelves.model.CleanPackageOption
import com.shuwei.intelligent.shelves.model.MealPackageOption
import com.shuwei.intelligent.shelves.model.ShelfModelV2
import com.shuwei.intelligent.shelves.model.SendWeightEvent
import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.net.NetViewModelV2
import com.shuwei.intelligent.shelves.net.RespData
import com.shuwei.intelligent.shelves.net.UiState
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.utils.KeyboardUtil
import com.shuwei.intelligent.shelves.utils.ext.addOnActionSearchListener
import com.shuwei.intelligent.shelves.utils.ext.clickWithDebounce
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@SuppressLint("NotifyDataSetChanged")
class ShelfV2Activity : BaseActivity() {
companion object {
const val SHELF_MODEL = "shelfModel"
const val YYYY_MM_DD__EEEE_HH_MM_SS = "yyyy年MM月dd日 EEEE***HH:mm:ss"
/** itemType=1:净菜包 */
private const val TYPE_CLEAN = 1
/** itemType=2:餐品净菜包 */
private const val TYPE_MEAL = 2
}
private lateinit var binding: ActivityShelfV2Binding
private var recordItem: ShelfModelV2? = null
private var realWeight: Int = 0
private var startTime = 0L
private val viewModel: NetViewModelV2 by viewModels()
// 当前选中类型,默认净菜包
private var currentType = TYPE_CLEAN
// 净菜包列表与 Adapter
private val cleanList: MutableList<CleanPackageOption> = mutableListOf()
private val cleanAdapter by lazy {
CleanPackageAdapter(cleanList).apply {
setOnDebouncedItemClick { _, _, position ->
cleanList.forEachIndexed { index, option -> option.isSelected = index == position }
notifyDataSetChanged()
binding.tvFoodName.text = cleanList[position].itemName
KeyboardUtil.hideKeyboard(this@ShelfV2Activity.window.decorView)
}
}
}
// 餐品净菜包列表与 Adapter
private val mealList: MutableList<MealPackageOption> = mutableListOf()
private val mealAdapter by lazy {
MealPackageAdapter(mealList).apply {
setOnDebouncedItemClick { _, _, position ->
mealList.forEachIndexed { index, option -> option.isSelected = index == position }
notifyDataSetChanged()
binding.tvFoodName.text = mealList[position].itemName
KeyboardUtil.hideKeyboard(this@ShelfV2Activity.window.decorView)
}
}
}
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityShelfV2Binding.inflate(layoutInflater)
setBackground()
setContentView(binding.root)
EventBus.getDefault().register(this)
recordItem = intent.getSerializableExtra(SHELF_MODEL) as? ShelfModelV2
recordItem?.let {
binding.tvShelfName.text = it.slotNo
binding.tvFoodName.text = if (it.itemName.isNullOrBlank()) "-" else it.itemName
val weightG = it.weight?.toDouble() ?: 0.0
realWeight = (weightG * 1000.0).toInt()
binding.tvFoodWeight.text = if (weightG >= 1.0) "%.3f千克".format(weightG)
else "${realWeight}"
startTime = System.currentTimeMillis()
}
initChipGroup()
initRecyclerView()
initSearchBox()
initButtons()
observeViewModel()
sendCmd(ScaleManager.buildGetStatusCmd())
loadList()
}
private fun initChipGroup() {
binding.chipGroupType.setOnCheckedStateChangeListener { _, checkedIds ->
currentType = when {
checkedIds.contains(R.id.chipClean) -> TYPE_CLEAN
checkedIds.contains(R.id.chipMeal) -> TYPE_MEAL
else -> currentType
}
// 切换时清空搜索框并重新加载列表
binding.etInputFood.setText("")
switchAdapter()
loadList()
}
}
private fun switchAdapter() {
val adapter: BaseQuickAdapter<*, *> =
if (currentType == TYPE_CLEAN) cleanAdapter else mealAdapter
binding.rvSearch.adapter = adapter
}
private fun initRecyclerView() {
binding.rvSearch.run {
layoutManager = GridLayoutManager(this@ShelfV2Activity, 2)
adapter = cleanAdapter
}
binding.refreshLayout.run {
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { loadList() }
}
}
private fun initSearchBox() {
binding.etInputFood.run {
addOnActionSearchListener {
loadList()
KeyboardUtil.hideKeyboard(context, this)
}
addTextChangedListener(afterTextChanged = { editable ->
if (editable.isNullOrBlank()) loadList()
})
}
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
}
private fun initButtons() {
binding.ivBack.setOnClickListener { finish() }
binding.btnClearZero.setOnClickListener {
Loading.show(this@ShelfV2Activity)
clearZero()
window.decorView.postDelayed({ Loading.dismiss() }, 10000)
}
binding.btnClearEmpty.setOnClickListener { v ->
clearZero()
binding.tvFoodName.text = "-"
Loading.show(this@ShelfV2Activity)
v.postDelayed({
Loading.dismiss()
// 3 秒后自动关闭 Activity 并回传空值
val emptyRecord = recordItem?.also {
it.itemName = ""
//4-空置
it.slotStatus = 4
// it.totalWeightG = null
// it.actualQty = null
it.weight = null
it.itemType = 0
it.traceCode = null
it.storeTime = null
it.spec = null
it.specLabel = null
it.cleanOrderNo = null
}
val result = Intent().apply {
putExtra(SHELF_MODEL, emptyRecord)
}
setResult(RESULT_OK, result)
finish()
}, 3000)
}
binding.btnConfirm.clickWithDebounce {
var shelfModelV2: ShelfModelV2? = null
if (currentType == TYPE_CLEAN) {
val item = cleanList.firstOrNull { it.isSelected }
if (item == null) {
toast("请选择净菜包"); return@clickWithDebounce
}
shelfModelV2 = item.toShelfModelV2(recordItem).also {
//1-存放中
it.slotStatus = 1
}
} else {
val item = mealList.firstOrNull { it.isSelected }
if (item == null) {
toast("请选择餐品净菜包"); return@clickWithDebounce
}
shelfModelV2 = item.toShelfModelV2(recordItem).also {
//1-存放中
it.slotStatus = 1
}
}
// 将选中结果回传给上层 Activity
val result = Intent().apply {
putExtra(SHELF_MODEL, shelfModelV2)
}
setResult(RESULT_OK, result)
finish()
}
}
private fun observeViewModel() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.cleanPackageOptionsUiState.collect { state ->
when (state) {
is UiState.Loading -> showProgress()
is UiState.Success<*> -> updateCleanList(state.data)
is UiState.Error -> showError(state.msg)
else -> {}
}
}
}
launch {
viewModel.mealPackageOptionsUiState.collect { state ->
when (state) {
is UiState.Loading -> showProgress()
is UiState.Success<*> -> updateMealList(state.data)
is UiState.Error -> showError(state.msg)
else -> {}
}
}
}
}
}
}
private fun loadList() {
val keyword = binding.etInputFood.text.trim().toString().ifBlank { null }
if (currentType == TYPE_CLEAN) {
viewModel.getCleanPackageOptions(keyword = keyword)
} else {
viewModel.getMealPackageOptions(keyword = keyword)
}
}
private fun showProgress() {
Loading.show(this)
}
@SuppressLint("NotifyDataSetChanged")
private fun updateCleanList(data: RespData<*>) {
finishRefresh()
binding.include?.root?.gone()
binding.root.postDelayed({ Loading.dismiss() }, 200)
@Suppress("UNCHECKED_CAST")
val items = data.data as? List<CleanPackageOption>
if (items.isNullOrEmpty()) {
loadEmptyView(); return
}
cleanList.clear()
cleanList.addAll(items)
cleanAdapter.notifyDataSetChanged()
}
@SuppressLint("NotifyDataSetChanged")
private fun updateMealList(data: RespData<*>) {
finishRefresh()
binding.include?.root?.gone()
binding.root.postDelayed({ Loading.dismiss() }, 200)
@Suppress("UNCHECKED_CAST")
val items = data.data as? List<MealPackageOption>
if (items.isNullOrEmpty()) {
loadEmptyView(); return
}
mealList.clear()
mealList.addAll(items)
mealAdapter.notifyDataSetChanged()
}
private fun showError(message: String) {
finishRefresh()
Loading.dismiss()
toast(message)
loadEmptyView()
}
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
cleanList.clear()
mealList.clear()
cleanAdapter.notifyDataSetChanged()
mealAdapter.notifyDataSetChanged()
binding.include?.let {
it.root.visible()
it.root.setOnClickListener { loadList() }
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_gray)
it.ivEmptyIcon.updateLayoutParams<LinearLayout.LayoutParams> {
val swDpValue = resources?.configuration?.smallestScreenWidthDp ?: 0
width = if (swDpValue > 600) 200.dp else 120.dp
}
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
}
}
private fun finishRefresh() {
binding.refreshLayout.let {
if (it.state == RefreshState.Refreshing) it.finishRefresh(500)
else if (it.state == RefreshState.Loading) it.finishLoadMore(500)
}
}
private fun clearZero() {
lifecycleScope.launch {
val slotNum = recordItem?.slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: 0
val zeroClearingCmd = ScaleManager.buildScaleZeroCmd(slotNum)
log("格口${recordItem?.slotNo}执行清零指令:$zeroClearingCmd")
sendCmd(zeroClearingCmd)
realWeight = 0
sendCmd(ScaleManager.buildGetStatusCmd())
startTime = System.currentTimeMillis()
}
KeyboardUtil.hideKeyboard(window.decorView)
}
override fun onClockTick() {
val sdf = SimpleDateFormat(YYYY_MM_DD__EEEE_HH_MM_SS, Locale.CHINA)
val arr = sdf.format(Date()).split("***")
updateLeftStatus(arr[0])
updateRightStatus(arr[1])
}
override fun onResume() {
super.onResume()
hideStatusBar()
}
override fun onDestroy() {
EventBus.getDefault().unregister(this)
super.onDestroy()
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun receiveWeightEvent(event: SendWeightEvent) {
val slotNum = recordItem?.slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: return
if (event.shelfNo != slotNum) return
val intervalTime = System.currentTimeMillis() - startTime
val weight = event.weight
log("格口${recordItem?.slotNo}获取重量:${weight}克,间隔:${intervalTime}ms")
binding.tvFoodWeight.text = if (weight < 1000) "${weight}"
else "%.3f千克".format(weight / 1000.0)
startTime = System.currentTimeMillis()
window.decorView.postDelayed({ Loading.dismiss() }, 1000)
}
}
@@ -0,0 +1,39 @@
package com.shuwei.intelligent.shelves.adapter
import android.content.Context
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemSearchBinding
import com.shuwei.intelligent.shelves.model.CleanPackageOption
class CleanPackageAdapter(list: MutableList<CleanPackageOption>) :
BaseQuickAdapter<CleanPackageOption, CleanPackageAdapter.VH>(list) {
inner class VH(val binding: ListItemSearchBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemSearchBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: CleanPackageOption?) {
item ?: return
val isSelected = item.isSelected
holder.binding.btnFoodName.run {
text = " ${item.itemName}"
setTextColor(getColor(if (isSelected) R.color.shelf_name_blue else R.color.black999))
strokeColor = ColorStateList.valueOf(
(if (isSelected) "#4969F5" else "#E6E6E6").toColorInt()
)
}
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -0,0 +1,41 @@
package com.shuwei.intelligent.shelves.adapter
import android.content.Context
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemSearchBinding
import com.shuwei.intelligent.shelves.model.MealPackageOption
class MealPackageAdapter(list: MutableList<MealPackageOption>) :
BaseQuickAdapter<MealPackageOption, MealPackageAdapter.VH>(list) {
inner class VH(val binding: ListItemSearchBinding) : QuickViewHolder(binding.root)
var selectedId: Long = -1L
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemSearchBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: MealPackageOption?) {
item ?: return
val isSelected = item.isSelected
holder.binding.btnFoodName.run {
text = " ${item.itemName}"
setTextColor(getColor(if (isSelected) R.color.shelf_name_blue else R.color.black999))
strokeColor = ColorStateList.valueOf(
(if (isSelected) "#4969F5" else "#E6E6E6").toColorInt()
)
}
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -0,0 +1,90 @@
package com.shuwei.intelligent.shelves.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.google.android.flexbox.FlexboxLayoutManager
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemShelfBinding
import com.shuwei.intelligent.shelves.model.ShelfModelV2
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.visible
import kotlin.math.abs
class ShelfV2Adapter(list: MutableList<ShelfModelV2>) :
BaseQuickAdapter<ShelfModelV2, ShelfV2Adapter.VH>(list) {
/** 每个格口 item 的高度(像素),由 Activity 动态计算 */
var itemHeight: Int = 0
/** 每个格口 item 的宽度(像素),由 Activity 动态计算 */
var itemWidth: Int = 0
inner class VH(val binding: ListItemShelfBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemShelfBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: ShelfModelV2?) {
item ?: return
// 动态设置 item 宽高
val lp = holder.itemView.layoutParams as? FlexboxLayoutManager.LayoutParams
lp?.apply {
if (itemWidth > 0) width = itemWidth
if (itemHeight > 0) height = itemHeight
}
val binding = holder.binding
// 格口编号
binding.tvShelfName.text = item.slotNo?.padStart(2, '0')
// 临期预警(slotStatus=2)显示过期图标
if (item.slotStatus.toInt() == 2) binding.ivStaleFood.visible()
else binding.ivStaleFood.gone()
// // 重量:itemType=2 用 totalWeightG(g)itemType=1 用 actualQty×1000(g)
// val weightG = if (item.itemType == 1) (item.actualQty?.toDouble() ?: 0.0) * 1000.0
// else item.totalWeightG?.toDouble() ?: 0.0
val weightG = item.weight?.toDouble() ?: 0.0
binding.tvFoodWeight.run {
setTextColor(getColor(R.color.food_weight_orange))
text = if (abs(weightG) >= 1.0) "%.3f千克".format(weightG)
else "${(weightG * 1000.0).toInt()}"
}
val isBlank = item.itemName.isNullOrBlank()
if (isBlank) {
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_blue))
binding.tvShelfName.setTextColor(getColor(R.color.shelf_name_white))
binding.tvFoodName.run {
setTextColor(getColor(R.color.white))
text = ""
}
binding.tvStoreDate.run {
setTextColor(getColor(R.color.food_weight_blue))
text = "-"
}
return
}
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_white))
binding.tvShelfName.setTextColor(getColor(R.color.shelf_name_blue))
binding.tvFoodName.run {
setTextColor(getColor(R.color.food_name_black))
text = item.itemName
}
binding.tvStoreDate.run {
setTextColor(getColor(R.color.black999))
text = item.storeTime
}
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -5,26 +5,33 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.PopupWindow
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.shuwei.intelligent.shelves.activity.HomeActivity
import com.shuwei.intelligent.shelves.activity.HomeActivity.Companion.LIGHT_CLOSE
import com.shuwei.intelligent.shelves.activity.HomeActivity.Companion.LIGHT_OPEN
import com.shuwei.intelligent.shelves.activity.LogActivity
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ActivityBaseBinding
import com.shuwei.intelligent.shelves.serial.SerialPortManager
import com.shuwei.intelligent.shelves.databinding.PopupRightStatusMenuBinding
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.LIGHT_CLOSE
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.LIGHT_OPEN
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.utils.FileLogger
import com.shuwei.intelligent.shelves.utils.ext.clickWithDebounce
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.startActivity
import kotlinx.coroutines.launch
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.shuwei.intelligent.shelves.utils.ext.setOnDoubleClickListener
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Suppress("DEPRECATION")
open class BaseActivity : AppCompatActivity() {
@@ -57,35 +64,28 @@ open class BaseActivity : AppCompatActivity() {
}
}
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// window.setDecorFitsSystemWindows(false)
// window.insetsController?.apply {
// hide(WindowInsets.Type.statusBars()) // 隐藏状态栏
// systemBarsBehavior =
// WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE // 滑动时临时显示
// }
// }
lightSwitchClick()
binding.tvLeftStatus.setOnDoubleClickListener {
finish()
//finish()
val intent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
}
binding.tvRightStatus.setOnDoubleClickListener {
if (enableRightStatusMenu) showRightStatusPopup(binding.tvRightStatus)
}
}
fun hideStatusBar() {
//// enableEdgeToEdge()
//// val uiOptions = (View.SYSTEM_UI_FLAG_FULLSCREEN // 隐藏状态栏
//// or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) // 隐藏导航栏(可选)
//// window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN
//
// ImmersionBar.with(this)
// .hideBar(BarHide.FLAG_HIDE_STATUS_BAR)
// .init()
}
/** 是否允许右侧状态栏双击弹出菜单,默认关闭,仅 HomeActivity 开启 */
protected open val enableRightStatusMenu: Boolean = false
fun hideStatusBar() {}
fun statusBarDarkFont() {
//window.decorView.systemUiVisibility = if (enable) View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR else 0
val color = ContextCompat.getColor(this, R.color.white)
binding.tvLeftStatus.setTextColor(color)
binding.tvRightStatus.setTextColor(color)
@@ -104,7 +104,7 @@ open class BaseActivity : AppCompatActivity() {
}
fun updateLeftStatus(data: String) {
binding.tvLeftStatus.text = data
binding.tvLeftStatus.text = data.ifBlank { " " }
}
fun updateRightStatus(data: String) {
@@ -112,14 +112,38 @@ open class BaseActivity : AppCompatActivity() {
}
// 5分钟
private val TIME_OUT: Long = (5 * 60 * 1000).toLong()
private val TIME_OUT: Long = (1 * 60 * 1000).toLong()
private val mHandler by lazy { Handler(Looper.getMainLooper()) }
private val mTimeoutRunnable = Runnable {
if (this is HomeActivity) {
this.switchLight(false)
// 用户长时间无操作时回调,子类可覆写实现具体逻辑(如关灯)
onUserInactive()
}
// ---- 右侧状态栏时钟 ----
// 默认时间格式:月日星期 时:分:秒
private val CLOCK_FORMAT = "MM月dd日 EEEE HH:mm:ss"
private val clockFormatter by lazy { SimpleDateFormat(CLOCK_FORMAT, Locale.CHINA) }
/**
* 每秒时钟回调,默认将当前时间显示在右侧状态栏。
* 子类可覆写以自定义格式或同时更新左侧状态栏。
*/
open fun onClockTick() {
updateRightStatus(clockFormatter.format(Date()))
}
private val clockTask = object : Runnable {
override fun run() {
onClockTick()
mHandler.postDelayed(this, 1000)
}
}
/**
* 用户超时无操作时回调,默认空实现,子类按需覆写
*/
open fun onUserInactive() {}
override fun onUserInteraction() {
super.onUserInteraction()
//开灯
@@ -136,14 +160,18 @@ open class BaseActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
// 启动定时器
// 启动超时定时器
mHandler.postDelayed(mTimeoutRunnable, TIME_OUT)
// 启动右侧状态栏时钟
mHandler.post(clockTask)
}
override fun onPause() {
super.onPause()
// 取消定时器
// 取消超时定时器
mHandler.removeCallbacks(mTimeoutRunnable)
// 停止右侧状态栏时钟
mHandler.removeCallbacks(clockTask)
}
fun switchLight(isOpen: Boolean) {
@@ -161,23 +189,33 @@ open class BaseActivity : AppCompatActivity() {
val tag = binding.tvLeftStatus.tag?.toString()?.toBoolean() ?: true
switchLight(tag.not())
}
binding.tvRightStatus.clickWithDebounce {
// if (this is HomeActivity) {
// startActivity<LogActivity> {
// putExtra(LogActivity.IS_LOG_DIR, true)
// }
// return@clickWithDebounce
// }
// startActivity<SettingActivity> { }
}
private fun showRightStatusPopup(anchor: View) {
val menuBinding = PopupRightStatusMenuBinding.inflate(LayoutInflater.from(this))
val popup = PopupWindow(
menuBinding.root,
120.dp,
101.dp,
true
)
menuBinding.tvEnvSwitch.setOnClickListener {
popup.dismiss()
EnvSwitchDialog(this).show()
}
menuBinding.tvLog.setOnClickListener {
popup.dismiss()
startActivity<LogActivity> {
putExtra(LogActivity.IS_LOG_DIR, true)
}
}
popup.showAsDropDown(anchor)
}
fun sendCmd(cmd:String) {
lifecycleScope.launch {
val result = SerialPortManager.send(cmd)
log("发送命令${cmd}结果:${result}")
}
fun sendCmd(cmd: String) {
ScaleManager.sendCmd(cmd)
log("发送命令: $cmd")
}
val fileLogger by lazy { FileLogger(this) }
@@ -0,0 +1,69 @@
package com.shuwei.intelligent.shelves.base
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import com.shuwei.intelligent.shelves.GlobalData
import com.shuwei.intelligent.shelves.databinding.DialogEnvSwitchBinding
import com.shuwei.intelligent.shelves.utils.SpTool
import com.shuwei.intelligent.shelves.utils.ToastUtil
import com.shuwei.intelligent.shelves.utils.ext.dp
/**
* 环境切换弹窗
*
* 提供 TEST / UAT / PROD 三套环境的切换功能,
* 初始化时自动根据 [GlobalData.appBaseUrl] 选中当前环境,
* 确认后更新 [GlobalData.appBaseUrl] 并通过 [onEnvChanged] 回调通知外部。
*
* @param context 上下文
* @param onEnvChanged 确认切换后的回调,参数为新的 baseUrl
*/
class EnvSwitchDialog(
context: Context,
private val onEnvChanged: (newBaseUrl: String) -> Unit = {}
) : BaseDialog(
context,
defWidth = 600.dp,
defHeight = WindowManager.LayoutParams.WRAP_CONTENT
) {
private lateinit var binding: DialogEnvSwitchBinding
override fun getRootView(): View {
binding = DialogEnvSwitchBinding.inflate(LayoutInflater.from(context))
return binding.root
}
override fun initView() {
// 根据当前 appBaseUrl 预选对应 RadioButton,不匹配则不选
when (GlobalData.appBaseUrl) {
GlobalData.LOCAL_BASE_URL -> binding.rbTest.isChecked = true
GlobalData.TEST_BASE_URL -> binding.rbUat.isChecked = true
GlobalData.PROD_BASE_URL -> binding.rbProd.isChecked = true
}
// 取消按钮
binding.btnCancel.setOnClickListener { dismiss() }
// 确认按钮
binding.btnConfirm.setOnClickListener {
// 未选中任何选项时提示用户
if (binding.rgEnv.checkedRadioButtonId == -1) {
ToastUtil.show(context,"请选择环境")
return@setOnClickListener
}
val newUrl = when (binding.rgEnv.checkedRadioButtonId) {
binding.rbTest.id -> GlobalData.LOCAL_BASE_URL
binding.rbUat.id -> GlobalData.TEST_BASE_URL
binding.rbProd.id -> GlobalData.PROD_BASE_URL
else -> return@setOnClickListener
}
GlobalData.appBaseUrl = newUrl
SpTool.baseUrl = newUrl
onEnvChanged(newUrl)
dismiss()
}
}
}
@@ -0,0 +1,14 @@
package com.shuwei.intelligent.shelves.model
/**
* 货柜初始化接口(4.1)响应体
*/
data class CabinetInitResult(
val canteenId: Long = 0,
val canteenName: String = "",
val deviceName: String = "",
val area: String = "",
val horizontalRows:Int = 0,
val verticalCount:Int = 0,
val containerGoodsList: List<ShelfModelV2>? = null,
)
@@ -0,0 +1,43 @@
package com.shuwei.intelligent.shelves.model
import com.shuwei.intelligent.shelves.utils.DateTimeUtil
import java.math.BigDecimal
import java.util.Date
/**
* 净菜包下拉选项,对应接口 4.2.1itemType=1
*/
data class CleanPackageOption(
val id: Long = 0,
val itemName: String = "",
/** 净菜订单号,sync 时作为 cleanOrderNo 回传 */
val orderNo: String = "",
val packageSpec: String? = null,
/** 关联菜品 id */
val foodId: Long? = null,
/** 需求份数 */
val needCount: Int? = null,
/** 需求计量(kg */
val needQtyKg: BigDecimal? = null,
/** 保质期至,格式 yyyy-MM-dd */
val expiryDate: String? = null,
/** 申领时间,格式 yyyy-MM-dd HH:mm:ss,仅订单有 */
val applyTime: String? = null,
/** 数据来源:1=净菜订单,2=食材库 */
val source: Short? = null,
/** 净菜类型 id,仅订单有 */
val vegTypeId: Long? = null,
/** 食材库 idsource=2 时有值,sync 时回传 materId */
val materId: Long? = null,
var isSelected: Boolean = false
) {
/** 将净菜包选项转换为格口记录 */
fun toShelfModelV2(model: ShelfModelV2? = null) = (model ?: ShelfModelV2()).also {
it.itemType = 1
it.itemName = itemName
it.cleanOrderNo = orderNo
it.spec = packageSpec
it.foodId = foodId
it.storeTime = DateTimeUtil.formatDateTime(dateTime = Date())
}
}
@@ -0,0 +1,37 @@
package com.shuwei.intelligent.shelves.model
import com.shuwei.intelligent.shelves.utils.DateTimeUtil
import java.math.BigDecimal
import java.util.Date
/**
* 餐品净菜包下拉选项,对应接口 4.2.2(itemType=2
*/
data class MealPackageOption(
val id: Long = 0,
val itemName: String = "",
/** 净菜订单号,sync 时作为 cleanOrderNo 回传 */
val orderNo: String = "",
val packageSpec: String? = null,
/** 关联菜品 id */
val foodId: Long? = null,
/** 需求份数 */
val needCount: Int? = null,
/** 需求计量(kg */
val needQtyKg: BigDecimal? = null,
/** 保质期至,格式 yyyy-MM-dd(包装完成后有值) */
val expiryDate: String? = null,
/** 数据来源:1=净菜订单,2=餐品库 */
val source: Short? = null,
var isSelected: Boolean = false
) {
/** 将餐品净菜包选项转换为格口记录 */
fun toShelfModelV2(model: ShelfModelV2? = null) = (model ?: ShelfModelV2()).also {
it.itemType = 2
it.itemName = itemName
it.cleanOrderNo = orderNo
it.spec = packageSpec
it.foodId = foodId
it.storeTime = DateTimeUtil.formatDateTime(dateTime = Date())
}
}
@@ -0,0 +1,18 @@
package com.shuwei.intelligent.shelves.model
/**
* 下拉列表分页请求体
*
* 用于净菜包/餐品净菜包下拉接口(POST),
* pageNum、pageSize 必填,keyword 可选:
* - 不传 keyword:返回净菜订单列表
* - 传 keyword:搜索食材库/餐品库
*/
data class PageOptionsReq(
/** 页码,从 1 开始 */
val pageNum: Long = 1,
/** 每页条数 */
val pageSize: Long = 50,
/** 搜索关键字,不传返回订单列表,传值搜索库表 */
val keyword: String? = null
)
@@ -0,0 +1,58 @@
package com.shuwei.intelligent.shelves.model
import java.io.Serializable
import java.math.BigDecimal
/**
* 格口存放记录,对应接口 4.1 货柜初始化返回的 containerGoodsList 元素
*/
data class ShelfModelV2(
var id: Long = 0,
/** 类型:1-食材净菜 / 2-餐品净菜包 */
var itemType: Int = 0,
/** 格口编号 */
var slotNo: String? = "",
/** 格口所在柜子编号 */
var cabinetId: String? = "",
/** 智能柜名称 */
var cabinetName: String? = "",
var area: String = "",
/** 溯源码 */
var traceCode: String? = "",
/** 关联净菜订单号 */
var cleanOrderNo: String? = "",
/** 物料名称 */
var itemName: String? = "",
/** 订单量 kgitemType=1 */
var orderQty: BigDecimal? = null,
/** 实收量 kgitemType=1 */
// var actualQty: BigDecimal? = null,
/** 差异量 kgitemType=1,可为负) */
var diffQty: BigDecimal? = null,
/** 规格(itemType=2,如:标准份) */
var spec: String? = null,
var specLabel: String? = null,
/** 包数(itemType=2 */
var pkgCount: Int? = null,
/** 总重量 gitemType=2 */
// var totalWeightG: BigDecimal? = null,
/** 统一重量字段,单位千克 */
var weight: BigDecimal? = null,
/** 验收结果:1-合格 / 2-轻微差异 / 3-不合格 / 4-待验 */
var checkResult: Short? = null,
/** 存储温度(℃) */
var temperature: BigDecimal? = null,
/** 存储湿度(% */
var humidity: BigDecimal? = null,
/** 剩余保质期(小时) */
var shelfLifeRemain: Int? = null,
var storeTime: String? = null,
/** 格口状态:1-存放中 / 2-临期预警 / 3-温度超标 / 4-空置 / 5-异常 */
var slotStatus: Short = 0,
/** 绑定菜品 iditemType=2 */
var foodId: Long? = null,
var unit: String? = null,
var remark: String? = null,
var canteenId: Long? = null,
var isClicked: Boolean = false
) : Serializable
@@ -0,0 +1,14 @@
package com.shuwei.intelligent.shelves.model
import java.math.BigDecimal
/**
* 全量同步整柜数据请求体,对应接口 4.3
* 注意:goodsList 为空或不传时,后端会软删除整柜所有格口数据
*/
data class SyncBody(
var cabinetId: String?=null,
var temperature: BigDecimal? = null,
var humidity: BigDecimal? = null,
var goodsList: List<ShelfModelV2>? = null,
)
@@ -0,0 +1,35 @@
package com.shuwei.intelligent.shelves.model
import java.math.BigDecimal
/**
* 全量同步单格口数据,对应接口 4.3 goodsList 元素
*/
data class SyncItem(
val slotNo: String,
/** 类型:1-食材净菜 / 2-餐品净菜包 */
val itemType: Short? = null,
val itemName: String? = null,
/** 关联净菜订单号(从下拉选项 orderNo 取值回传) */
val cleanOrderNo: String? = null,
/** 绑定菜品 iditemType=2 */
val foodId: Long? = null,
/** 订单量 kgitemType=1 */
val orderQty: BigDecimal? = null,
/** 实收量 kgitemType=1,与 totalWeightG 二选一) */
val actualQty: BigDecimal? = null,
/** 总重量 gitemType=2,后端自动换算为 kg 存储) */
val totalWeightG: BigDecimal? = null,
/** 规格:1-标准份 / 2-大份 / 3-小份(itemType=2 */
val spec: Int? = null,
val pkgCount: Int? = null,
/** 验收结果:1-合格 / 2-轻微差异 / 3-不合格 / 4-待验 */
val checkResult: Short? = null,
/** 格口状态:1-存放中 / 2-临期预警 / 3-温度超标 / 4-空置 / 5-异常 */
val slotStatus: Short? = null,
/** 剩余保质期(小时) */
val shelfLifeRemain: Int? = null,
/** 存入时间,仅新增时生效,不传则取当前时间 */
val storeTime: String? = null,
val remark: String? = null,
)
@@ -13,29 +13,29 @@ import retrofit2.http.Query
import retrofit2.http.Url
interface ApiService {
/**
* device获取token
*/
@GET("/sys/getEquipmentToken")
suspend fun getDeviceToken(
@Query("qrcodeId") qrcodeId: String,
@Query("appVersion") appVersion: String = App.appVersion
): RespData<String>
/**
*获取配置信息
*/
@GET("/equipment/stEquipment/queryByEquipmentCode")
suspend fun getDeviceInfo(
@Query("equipmentCode") equipmentCode: String,
@Query("appVersion") appVersion: String = App.appVersion
): RespData<DeviceConfigInfo>
@GET
suspend fun getAccessToken(
@Url url: String = UrlConfig.GET_ACCESS_TOKEN,
@Query("qrcodeId") qrcodeId: String
): RespData<String?>
// /**
// * device获取token
// */
// @GET("/sys/getEquipmentToken")
// suspend fun getDeviceToken(
// @Query("qrcodeId") qrcodeId: String,
// @Query("appVersion") appVersion: String = App.appVersion
// ): RespData<String>
//
// /**
// *获取配置信息
// */
// @GET("/equipment/stEquipment/queryByEquipmentCode")
// suspend fun getDeviceInfo(
// @Query("equipmentCode") equipmentCode: String,
// @Query("appVersion") appVersion: String = App.appVersion
// ): RespData<DeviceConfigInfo>
//
// @GET
// suspend fun getAccessToken(
// @Url url: String = UrlConfig.GET_ACCESS_TOKEN,
// @Query("qrcodeId") qrcodeId: String
// ): RespData<String?>
@GET
suspend fun getShelfList(
@@ -0,0 +1,55 @@
package com.shuwei.intelligent.shelves.net
import com.shuwei.intelligent.shelves.model.CabinetInitResult
import com.shuwei.intelligent.shelves.model.CleanPackageOption
import com.shuwei.intelligent.shelves.model.MealPackageOption
import com.shuwei.intelligent.shelves.model.PageOptionsReq
import com.shuwei.intelligent.shelves.model.SyncBody
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import retrofit2.http.Url
interface ApiServiceV2 {
/**
* 4.1 货柜初始化
* 设备启动、Token 刷新后、首页重试时调用
*/
@GET
suspend fun initCabinet(
@Url url: String = UrlConfig.CABINET_INIT,
@Query("cabinetId") cabinetId: String
): RespData<CabinetInitResult>
/**
* 4.2.1 净菜包下拉列表(itemType=1
* POST + JSON Body 分页请求,data 直接为列表数组
*/
@POST
suspend fun getCleanPackageOptions(
@Url url: String = UrlConfig.CLEAN_PACKAGE_OPTIONS,
@Body body: PageOptionsReq
): RespData<List<CleanPackageOption>>
/**
* 4.2.2 餐品净菜包下拉列表(itemType=2)
* POST + JSON Body 分页请求,data 直接为列表数组
*/
@POST
suspend fun getMealPackageOptions(
@Url url: String = UrlConfig.MEAL_PACKAGE_OPTIONS,
@Body body: PageOptionsReq
): RespData<List<MealPackageOption>>
/**
* 4.3 全量同步整柜数据(定时上报)
* 警告:body.goodsList 为 null 或空列表时,后端会软删除整柜所有格口数据
*/
@POST
suspend fun syncCabinetData(
@Url url: String = UrlConfig.CABINET_RECORD_SYNC,
@Body body: SyncBody
): RespData<Any?>
}
@@ -0,0 +1,21 @@
package com.shuwei.intelligent.shelves.net
import com.shuwei.intelligent.shelves.App
import okhttp3.Interceptor
import okhttp3.Response
/**
* v1.1 接口专用拦截器
* /nutrition/neglect/ 路径无需鉴权,仅注入基础请求头
*/
class CabinetRequestInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-DEVICE-CODE", App.deviceId)
.header("authorization", "57ee87183f2a4fa59683ec9ef41c8f5d")
.build()
return chain.proceed(request)
}
}
@@ -2,7 +2,10 @@ package com.shuwei.intelligent.shelves.net
import android.annotation.SuppressLint
import android.util.Log
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.BuildConfig
import com.shuwei.intelligent.shelves.GlobalData
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
@@ -16,33 +19,55 @@ import java.security.SecureRandom
import java.security.cert.X509Certificate
import kotlin.apply
/** 动态域名拦截器:将 Retrofit 请求的 baseUrl 实时替换为 [GlobalData.appBaseUrl] */
class DynamicBaseUrlInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val newUrl = GlobalData.appBaseUrl.toHttpUrlOrNull() ?: return chain.proceed(request)
val url = request.url.newBuilder()
.scheme(newUrl.scheme)
.host(newUrl.host)
.port(newUrl.port)
.build()
return chain.proceed(request.newBuilder().url(url).build())
}
}
val apiService: ApiService = Retrofit.Builder()
// .baseUrl(UrlConfig.BASE_URL)
.baseUrl(UrlConfig.DEVICE_BASE_URL)
.baseUrl("http://placeholder/")
.client(HttpManager.instance.client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
/** v1.1 接口专用实例,使用轻量拦截器,不携带旧鉴权 Token */
val apiServiceV2: ApiServiceV2 = Retrofit.Builder()
.baseUrl("http://placeholder/")
.client(HttpManager.instance.cabinetClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiServiceV2::class.java)
class HttpManager private constructor() {
val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.apply {
connectTimeout(15, TimeUnit.SECONDS)
connectTimeout(30, TimeUnit.SECONDS)
readTimeout(30, TimeUnit.SECONDS)
writeTimeout(15, TimeUnit.SECONDS)
writeTimeout(30, TimeUnit.SECONDS)
sslSocketFactory(createSSLSocketFactory(), TrustAllCerts())
hostnameVerifier { _, _ -> true }
// addInterceptor(LoggingInterceptor())
addInterceptor(DynamicBaseUrlInterceptor())
addNetworkInterceptor(HttpLoggingInterceptor(logger = {
Log.d("HttpManager","okhttp logger ==>${it}")
}).apply {
level =
// if (MyApp.DEBUG) {
if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
// } else {
// HttpLoggingInterceptor.Level.NONE
// }
} else {
HttpLoggingInterceptor.Level.NONE
}
})
// addInterceptor { chain ->
// val request = chain.request().newBuilder()
@@ -55,6 +80,27 @@ class HttpManager private constructor() {
.build()
}
/** v1.1 接口专用 OkHttpClient,仅注入 Content-Type、Accept、X-DEVICE-CODE */
val cabinetClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.apply {
connectTimeout(30, TimeUnit.SECONDS)
readTimeout(30, TimeUnit.SECONDS)
writeTimeout(30, TimeUnit.SECONDS)
sslSocketFactory(createSSLSocketFactory(), TrustAllCerts())
hostnameVerifier { _, _ -> true }
addInterceptor(DynamicBaseUrlInterceptor())
addNetworkInterceptor(HttpLoggingInterceptor(logger = {
Log.d("HttpManager", "v2 okhttp ==>${it}")
}).apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
})
addInterceptor(CabinetRequestInterceptor())
}
.build()
}
companion object {
val instance by lazy { HttpManager() }
}
@@ -5,31 +5,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.model.ShelfBody
import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.net.HttpManager.Companion.instance
import com.shuwei.intelligent.shelves.utils.ext.toJsonString
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
class NetViewModel : ViewModel() {
// val apiService: ApiService = HttpManager.instance.apiService
// val apiService: ApiService = Retrofit.Builder()
// .baseUrl(UrlConfig.BASE_URL)
// .client(HttpManager.instance.client)
// .addConverterFactory(GsonConverterFactory.create())
// .build()
// .create(ApiService::class.java)
private val _getShelfListUiState = MutableStateFlow<UiState>(UiState.Initial)
val getShelfListUiState: StateFlow<UiState> = _getShelfListUiState
// private val _saveShelfGoodsListUiState = MutableStateFlow<UiState>(UiState.Initial)
// val saveShelfGoodsListUiState: StateFlow<UiState> = _saveShelfGoodsListUiState
private val _getGoodsListUiState = MutableStateFlow<UiState>(UiState.Initial)
val getGoodsListUiState: StateFlow<UiState> = _getGoodsListUiState
@@ -42,55 +27,56 @@ class NetViewModel : ViewModel() {
private val _getDeviceConfigUiState = MutableStateFlow<UiState>(UiState.Initial)
val getDeviceConfigUiState: StateFlow<UiState> = _getDeviceConfigUiState
private val _updateOverdueStateUiState = MutableStateFlow<UiState>(UiState.Initial)
val updateOverdueStateUiState: StateFlow<UiState> = _updateOverdueStateUiState
fun getDeviceToken(deviceId: String) {
viewModelScope.launch {
_getDeviceTokenUiState.value = UiState.Loading
runCatching {
val response = apiService.getDeviceToken(qrcodeId = deviceId)
if (response.isSuccess()) {
_getDeviceTokenUiState.value = UiState.Success(response)
} else {
_getDeviceTokenUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_getDeviceTokenUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
// fun getDeviceToken(deviceId: String) {
// viewModelScope.launch {
// _getDeviceTokenUiState.value = UiState.Loading
// runCatching {
// val response = apiService.getDeviceToken(qrcodeId = deviceId)
// if (response.isSuccess()) {
// _getDeviceTokenUiState.value = UiState.Success(response)
// } else {
// _getDeviceTokenUiState.value = UiState.Error(response.msg ?: "请求失败")
// }
// }.onFailure {
// _getDeviceTokenUiState.value = UiState.Error(it.message ?: "请求异常")
// }
// }
// }
fun getDeviceConfig(deviceId: String, deviceToken: String) {
viewModelScope.launch {
_getDeviceTokenUiState.value = UiState.Loading
runCatching {
val response =
apiService.getDeviceInfo(equipmentCode = deviceId)
if (response.isSuccess()) {
_getDeviceTokenUiState.value = UiState.Success(response)
} else {
_getDeviceTokenUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_getDeviceTokenUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
// fun getDeviceConfig(deviceId: String, deviceToken: String) {
// viewModelScope.launch {
// _getDeviceConfigUiState.value = UiState.Loading
// runCatching {
// val response = apiService.getDeviceInfo(equipmentCode = deviceId)
// if (response.isSuccess()) {
// _getDeviceConfigUiState.value = UiState.Success(response)
// } else {
// _getDeviceConfigUiState.value = UiState.Error(response.msg ?: "请求失败")
// }
// }.onFailure {
// _getDeviceConfigUiState.value = UiState.Error(it.message ?: "请求异常")
// }
// }
// }
fun getAccessToken(deviceId: String) {
viewModelScope.launch {
_getAccessTokenUiState.value = UiState.Loading
runCatching {
val response = apiService.getAccessToken(qrcodeId = deviceId)
if (response.isSuccess()) {
_getAccessTokenUiState.value = UiState.Success(response)
} else {
_getAccessTokenUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_getAccessTokenUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
// fun getAccessToken(deviceId: String) {
// viewModelScope.launch {
// _getAccessTokenUiState.value = UiState.Loading
// runCatching {
// val response = apiService.getAccessToken(qrcodeId = deviceId)
// if (response.isSuccess()) {
// _getAccessTokenUiState.value = UiState.Success(response)
// } else {
// _getAccessTokenUiState.value = UiState.Error(response.msg ?: "请求失败")
// }
// }.onFailure {
// _getAccessTokenUiState.value = UiState.Error(it.message ?: "请求异常")
// }
// }
// }
fun getShelfList(deviceId: String) {
viewModelScope.launch {
@@ -108,37 +94,21 @@ class NetViewModel : ViewModel() {
}
}
fun updateOverdueState(block:(Boolean, List<ShelfModel>?)-> Unit) {
fun updateOverdueState() {
viewModelScope.launch {
runCatching {
val response = apiService.getShelfList(deviceId = App.deviceId)
if (response.isSuccess()) {
block(true, response.data?.containerGoodsList)
_updateOverdueStateUiState.value = UiState.Success(response)
} else {
block(false, emptyList())
_updateOverdueStateUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
block(false, null)
_updateOverdueStateUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
// fun saveShelfGoodsList(body: ShelfBody) {
// viewModelScope.launch {
// _saveShelfGoodsListUiState.value = UiState.Loading
// runCatching {
// val response = apiService.saveShelfGoodsList(body)
// if (response.success) {
// _saveShelfGoodsListUiState.value = UiState.Success(response)
// } else {
// _saveShelfGoodsListUiState.value = UiState.Error(response.message ?: "请求失败")
// }
// }.onFailure {
// _saveShelfGoodsListUiState.value = UiState.Error(it.message ?: "请求异常")
// }
// }
// }
fun getGoodsList(
canteenId: String = "",
goodsName: String = "",
@@ -148,9 +118,6 @@ class NetViewModel : ViewModel() {
viewModelScope.launch {
_getGoodsListUiState.value = UiState.Loading
runCatching {
if (App.configUrl.isNullOrBlank()) {
App.configUrl = UrlConfig.BASE_URL
}
val response = apiService.getGoodsList(
canteenId = canteenId,
goodsName = goodsName,
@@ -178,7 +145,5 @@ class NetViewModel : ViewModel() {
Log.d("NetViewModel", "saveShelfGoodsList异常:${it.message}")
}
}
}
}
@@ -0,0 +1,104 @@
package com.shuwei.intelligent.shelves.net
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shuwei.intelligent.shelves.model.CabinetInitResult
import com.shuwei.intelligent.shelves.model.CleanPackageOption
import com.shuwei.intelligent.shelves.model.MealPackageOption
import com.shuwei.intelligent.shelves.model.PageOptionsReq
import com.shuwei.intelligent.shelves.model.SyncBody
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class NetViewModelV2 : ViewModel() {
private val _initCabinetUiState = MutableStateFlow<UiState>(UiState.Initial)
val initCabinetUiState: StateFlow<UiState> = _initCabinetUiState
private val _cleanPackageOptionsUiState = MutableStateFlow<UiState>(UiState.Initial)
val cleanPackageOptionsUiState: StateFlow<UiState> = _cleanPackageOptionsUiState
private val _mealPackageOptionsUiState = MutableStateFlow<UiState>(UiState.Initial)
val mealPackageOptionsUiState: StateFlow<UiState> = _mealPackageOptionsUiState
private val _syncCabinetUiState = MutableStateFlow<UiState>(UiState.Initial)
val syncCabinetUiState: StateFlow<UiState> = _syncCabinetUiState
/** 4.1 货柜初始化 */
fun initCabinet(cabinetId: String) {
viewModelScope.launch {
_initCabinetUiState.value = UiState.Loading
runCatching {
val response = apiServiceV2.initCabinet(cabinetId = cabinetId)
if (response.isSuccess()) {
_initCabinetUiState.value = UiState.Success(response)
} else {
_initCabinetUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_initCabinetUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
/** 4.2.1 净菜包下拉列表(itemType=1 */
fun getCleanPackageOptions(keyword: String? = null) {
viewModelScope.launch {
_cleanPackageOptionsUiState.value = UiState.Loading
runCatching {
val response = apiServiceV2.getCleanPackageOptions(
body = PageOptionsReq(pageNum = 1, pageSize = 50, keyword = keyword)
)
if (response.isSuccess()) {
_cleanPackageOptionsUiState.value = UiState.Success(response)
} else {
_cleanPackageOptionsUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_cleanPackageOptionsUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
/** 4.2.2 餐品净菜包下拉列表(itemType=2 */
fun getMealPackageOptions(keyword: String? = null) {
viewModelScope.launch {
_mealPackageOptionsUiState.value = UiState.Loading
runCatching {
val response = apiServiceV2.getMealPackageOptions(
body = PageOptionsReq(pageNum = 1, pageSize = 50, keyword = keyword)
)
if (response.isSuccess()) {
_mealPackageOptionsUiState.value = UiState.Success(response)
} else {
_mealPackageOptionsUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_mealPackageOptionsUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
/**
* 4.3 全量同步整柜数据
* 警告:body.goodsList 为 null 或空列表时,后端会软删除整柜所有格口数据
*/
fun syncCabinetData(body: SyncBody) {
viewModelScope.launch {
_syncCabinetUiState.value = UiState.Loading
runCatching {
val response = apiServiceV2.syncCabinetData(body = body)
if (response.isSuccess()) {
_syncCabinetUiState.value = UiState.Success(response)
} else {
_syncCabinetUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
Log.e("NetViewModelV2", "syncCabinetData 异常:${it.message}")
_syncCabinetUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
}
@@ -1,6 +1,7 @@
package com.shuwei.intelligent.shelves.net
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.GlobalData
object UrlConfig {
// const val DEVICE_BASE_URL = "http://device.shuziweidao.com:8889"
@@ -13,18 +14,24 @@ object UrlConfig {
// */
// const val DEVICE_CONFIG = "${DEVICE_BASE_URL}/equipment/stEquipment/queryByEquipmentCode"
const val BASE_URL = "https://dev.yixiong-tech.com:8081"
// const val BASE_URL = "https://yyjk.shuziweidao.com/gateway"
// const val BASE_URL = "http://192.168.1.201:14801"
const val DEVICE_BASE_URL = BASE_URL
// const val BASE_URL = "https://dev.yixiong-tech.com:8081"
//// const val BASE_URL = "https://yyjk.shuziweidao.com/gateway"
//// const val BASE_URL = "http://192.168.1.201:14801"
// const val DEVICE_BASE_URL = BASE_URL
var GET_ACCESS_TOKEN = "${App.configUrl}/restaurant/equipment/stEquipment/getEquipmentToken"
var GET_ACCESS_TOKEN = "${GlobalData.appBaseUrl}/restaurant/equipment/stEquipment/getEquipmentToken"
var GET_SHELF_LIST =
"${App.configUrl}/terminal/neglect/smartShelves/app/smartShelves/app/getShelvesListByDeviceId"
"${GlobalData.appBaseUrl}/terminal/neglect/smartShelves/app/smartShelves/app/getShelvesListByDeviceId"
var GET_GOODS_LIST =
"${App.configUrl}/terminal/neglect/smartShelves/app/smartShelves/app/getGoodsPageList"
"${GlobalData.appBaseUrl}/terminal/neglect/smartShelves/app/smartShelves/app/getGoodsPageList"
var SAVE_SHELF_GOODS_LIST =
"${App.configUrl}/terminal/neglect/smartShelves/app/smartShelves/app/saveShelvesGoodsList"
"${GlobalData.appBaseUrl}/terminal/neglect/smartShelves/app/smartShelves/app/saveShelvesGoodsList"
// v1.1 新接口
var CABINET_INIT = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/init"
var CLEAN_PACKAGE_OPTIONS = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/clean-package-options"
var MEAL_PACKAGE_OPTIONS = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/meal-package-options"
var CABINET_RECORD_SYNC = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/record/sync"
}
@@ -0,0 +1,24 @@
package com.shuwei.intelligent.shelves.serial
/**
* 心跳数据(命令 0x01 / 0x81,终端发起)
*
* @param scaleCount 秤的数量
* @param weights 各台秤的重量列表,索引从1开始
* @param simCardNo 物联卡号(20字节ASCII
* @param lock1Door 锁1门状态:0-关 1-开
* @param lock1Lock 锁1锁状态:0-关 1-开
* @param lock2Door 锁2门状态:0-关 1-开
* @param lock2Lock 锁2锁状态:0-关 1-开
* @param temperature 温度值(原始字节,单位由温控器决定)
*/
data class HeartbeatData(
val scaleCount: Int,
val weights: List<ScaleWeight>,
val simCardNo: String,
val lock1Door: Int,
val lock1Lock: Int,
val lock2Door: Int,
val lock2Lock: Int,
val temperature: Int
)
@@ -0,0 +1,182 @@
package com.shuwei.intelligent.shelves.serial
import com.shuwei.intelligent.shelves.App
/**
* 串口通信协议常量定义
* 统一管理所有协议头、协议尾、各类指令及帧解析偏移量
*/
object ProtocolConstants {
/** 协议头 */
const val HEADER = "D6DADEDB"
/** 协议尾 */
const val FOOTER = "FFFF"
// ----------------------------------------------------------------
// 指令码(cmd 字段,4字符)
// ----------------------------------------------------------------
/** 终端激活指令 */
const val CMD_ACTIVATE = "0001"
/** 心跳指令(服务端发起) */
const val CMD_HEARTBEAT = "0101"
/** 心跳指令(终端发起) */
const val CMD_HEARTBEAT_TERMINAL = "8101"
/** 开关锁反馈 */
const val CMD_LOCK_FEEDBACK = "0202"
/** 获取设备状态 */
const val CMD_DEVICE_STATUS = "0302"
/** 卡号上发 */
const val CMD_CARD_UPLOAD = "0501"
/** 操作记录上传 */
const val CMD_OPERATION_RECORD = "0B01"
/** 对时 */
const val CMD_TIME_SYNC = "0C01"
/** 终端位置上报 */
const val CMD_POSITION_REPORT = "0E01"
/** 温控信息上报 */
const val CMD_TEMP_REPORT = "1001"
/** 温控信息应答 */
const val CMD_TEMP_RESPONSE = "1002"
/** 温控应答(备用) */
const val CMD_TEMP_RESPONSE_ALT = "0F02"
// ----------------------------------------------------------------
// 帧数据偏移 / 尺寸常量
// ----------------------------------------------------------------
/** 心跳帧中重量数据起始偏移(相对帧起始) */
const val HEARTBEAT_WEIGHT_OFFSET = 14
/** 设备状态帧中重量数据起始偏移(含协议头长度) */
const val DEVICE_STATUS_WEIGHT_OFFSET = HEADER.length + 14
/** 货架数量(每侧5格,共10格,协议中12组) */
fun shelfSensorCount(): Int {
return if (device2Columns.contains(App.deviceId)) 12
else if (device3Columns.contains(App.deviceId)) 24
else 0
}
/** 每组重量数据字节数(十六进制字符数) */
const val BYTES_PER_WEIGHT = 8
/** 温度字段字符长度 */
const val TEMPERATURE_FIELD_LEN = 2
/** 心跳帧最小有效长度 */
fun heartbeatMinLength(): Int {
return if (device2Columns.contains(App.deviceId)) 200
else if (device3Columns.contains(App.deviceId)) 296
else 0
}
/** 心跳帧中温度字段起始偏移(经协议分析得出,前面含重量+其他传感器数据) */
fun heartbeatTemperatureStart(): Int {
return if (device2Columns.contains(App.deviceId)) 158
else if (device3Columns.contains(App.deviceId)) 158+96
else 0
}
/** 激活请求体有效长度 */
const val ACTIVATE_BODY_LENGTH = 44
/** 固定数据体有效长度(terminalId 30 + terminalVersion 10 + 其他 2 */
const val FIX_BODY_LENGTH = 42
// ----------------------------------------------------------------
// 完整指令
// ----------------------------------------------------------------
/** 激活指令(1楼餐厅) */
// const val ACTIVE_CMD =
// "${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}"
// 410激活码
// const val ACTIVE_CMD = "${HEADER}000281C5D70A7D42894EA17650F5D0BB516838E7863C9ABD766C38958AFF0F3C062D${FOOTER}"
/** 左货架开锁指令 */
const val LEFT_SHELF_OPEN_CMD =
"${HEADER}020101000000000000000000000000000000000000${FOOTER}"
/** 右货架开锁指令 */
const val RIGHT_SHELF_OPEN_CMD =
"${HEADER}020100010000000000000000000000000000000000${FOOTER}"
/** 获取设备信息指令 */
const val DEVICE_INFO_CMD = "${HEADER}0301000000000000000000000000${FOOTER}"
/** 启动温控指令 */
const val START_TEMP_CTRL_CMD = "${HEADER}0F01360000${FOOTER}"
/** 华氏温度单位指令 */
const val F_TEMP_CMD = "${HEADER}0F01390000${FOOTER}"
/** 摄氏温度单位指令 */
const val C_TEMP_CMD = "${HEADER}0F01390001${FOOTER}"
/** 关灯指令(继电器1关) */
const val LIGHT_CLOSE = "${HEADER}020102020102020000000000000000000000000000${FOOTER}"
/** 开灯指令(继电器1开) */
const val LIGHT_OPEN = "${HEADER}020102020002020000000000000000000000000000${FOOTER}"
/** 温控指令 */
const val TEMPERATURE_CTRL = "${HEADER}0F01040000${FOOTER}"
/**
* 设备2列,10格
*/
val device2Columns = listOf(
//原410货柜
"7a991439-3a12-3ef7-809b-c0258b839473",
//原1楼餐厅货柜
"4787e213-90ab-3e32-88e0-ac271a937751",
// TODO: 测试设备----------
"2987f0c5-5754-33e9-b00a-251db5e2e55f"
)
/**
* 设备3列,15格,左侧1列竖向编号1、2、3、4、5,右侧2列5行编号为6-7、8-9、10-11、12-13、14-15
*/
val device3ColumnsOrder = listOf(1, 6, 7, 2, 8, 9, 3, 10, 11, 4, 12, 13, 5, 14, 15)
/**
* 设备2列,10格,左侧1列竖向编号1、2、3、4、5,右侧1列6,7,8,9,10
*/
val device2ColumnsOrder = listOf(1, 6, 2, 7, 3, 8, 4, 9, 5, 10)
/**
* 设备3列,15格,左侧1列竖向编号1、2、3、4、5,右侧2列5行编号为6-7、8-9、10-11、12-13、14-15
*/
val device3Columns = listOf(
//新设备1
"d369f9b0-1c5b-3066-ba72-988e1449cecc",
//新设备2
"505ce1b1-facc-3eb2-855a-3dadda5ba358"
)
/** 激活指令mapkey为设备号,value为激活码 */
val ACTIVE_MAP = mutableMapOf(
//原410货柜
"7a991439-3a12-3ef7-809b-c0258b839473" to "${HEADER}000281C5D70A7D42894EA17650F5D0BB516838E7863C9ABD766C38958AFF0F3C062D${FOOTER}",
//原1楼餐厅货柜
"4787e213-90ab-3e32-88e0-ac271a937751" to "${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}",
//新设备1,临时使用1楼餐厅激活码
"d369f9b0-1c5b-3066-ba72-988e1449cecc" to "${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}",
//新设备2,临时使用1楼餐厅激活码
"505ce1b1-facc-3eb2-855a-3dadda5ba358" to "${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}",
)
}
@@ -0,0 +1,42 @@
package com.shuwei.intelligent.shelves.serial
/**
* 协议帧通用数据结构
* 每一帧解析后的原始信息,所有命令共用此结构
*
* @param cmd 命令字节,对应 ScaleProtocolConstants.CMD_* 常量
* @param flag 标志字节
* @param rawBody 数据体原始字节(不含协议头尾、命令、标志、固定数据体)
* @param terminalId 终端ID15字节),ASCII字符串
* @param firmwareVersion 固件版本(5字节),ASCII字符串
* @param cmdCount 命令次数
*/
data class ScaleFrame(
val cmd: Byte,
val flag: Byte,
val rawBody: ByteArray,
val terminalId: String,
val firmwareVersion: String,
val cmdCount: Int
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ScaleFrame) return false
return cmd == other.cmd &&
flag == other.flag &&
rawBody.contentEquals(other.rawBody) &&
terminalId == other.terminalId &&
firmwareVersion == other.firmwareVersion &&
cmdCount == other.cmdCount
}
override fun hashCode(): Int {
var result = cmd.toInt()
result = 31 * result + flag.toInt()
result = 31 * result + rawBody.contentHashCode()
result = 31 * result + terminalId.hashCode()
result = 31 * result + firmwareVersion.hashCode()
result = 31 * result + cmdCount
return result
}
}
@@ -0,0 +1,281 @@
package com.shuwei.intelligent.shelves.serial
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.BODY_OFFSET
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.BYTES_PER_WEIGHT
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FIRMWARE_VERSION_LEN
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FIXED_BODY_LEN
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FOOTER_HEX
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FOOTER_LEN
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.HEADER_HEX
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.SIM_CARD_NO_LEN
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.TERMINAL_ID_LEN
import com.shuwei.intelligent.shelves.utils.hexToBytes
/**
* 协议帧组装与解析器
*
* 职责一:将串口原始分片十六进制字符串拼装为完整协议帧
* 职责二:将完整帧解析为 ScaleFrame,自动提取固定数据体字段
* 职责三:提供心跳、状态等数据体的具体解析方法
* 职责四:提供重量字节解析工具(BCD编码、小端序、最高位符号位)
*
* 设计为无业务依赖,可独立复制到其他项目使用
*/
class ScaleFrameParser {
/** 分片拼装缓冲区 */
private val buffer = StringBuilder()
// ----------------------------------------------------------------
// 一、帧组装(串口分片 -> 完整帧十六进制字符串)
// ----------------------------------------------------------------
/**
* 追加串口原始分片数据,尝试拼装为完整帧
*
* @param srcData 串口收到的原始十六进制字符串片段
* @return 完整协议帧字符串;帧尚不完整时返回 null
*/
fun appendData(srcData: String): String? {
val hasHeader = srcData.contains(HEADER_HEX)
val hasFooter = srcData.contains(FOOTER_HEX)
return when {
hasHeader && hasFooter -> parseCompleteInSingleChunk(srcData)
hasHeader && !hasFooter -> {
// 只有帧头,等待后续分片
buffer.clear()
buffer.append(srcData.substring(srcData.lastIndexOf(HEADER_HEX))).append(",")
null
}
!hasHeader && !hasFooter -> {
// 中间分片,追加缓冲
buffer.append(srcData).append(",")
null
}
else -> {
// 只有帧尾,与缓冲区拼接成完整帧
val endIndex = srcData.indexOf(FOOTER_HEX) + FOOTER_HEX.length
buffer.append(srcData.substring(0, endIndex))
val frame = buffer.toString().replace(",", "")
buffer.clear()
frame.takeIf { it.isNotEmpty() }
}
}
}
/**
* 处理单个分片中同时含有帧头和帧尾的情况
*/
private fun parseCompleteInSingleChunk(srcData: String): String? {
buffer.clear()
val startIndex = srcData.lastIndexOf(HEADER_HEX)
val endIndex = srcData.indexOf(FOOTER_HEX) + FOOTER_HEX.length
return if (endIndex <= startIndex) {
// 帧尾在帧头之前:是上一帧的残留尾部 + 新帧头部
val partial = srcData.substring(startIndex)
if (partial.contains(FOOTER_HEX)) {
val newEnd = partial.indexOf(FOOTER_HEX) + FOOTER_HEX.length
partial.substring(0, newEnd)
} else {
buffer.append(partial).append(",")
null
}
} else {
// 正常完整帧
srcData.substring(startIndex, endIndex)
}
}
/** 清空分片缓冲区 */
fun reset() {
buffer.clear()
}
// ----------------------------------------------------------------
// 二、帧解析(完整帧十六进制字符串 -> ScaleFrame
// ----------------------------------------------------------------
/**
* 解析完整协议帧为 ScaleFrame
*
* 帧结构:[协议头4] [CMD1] [FLAG1] [数据体N] [终端ID15] [固件版本5] [命令次数1] [协议尾2]
*
* @param hexFrame 完整帧十六进制字符串(含协议头尾)
* @return 解析成功返回 ScaleFrame;校验失败或格式错误返回 null
*/
fun parseFrame(hexFrame: String): ScaleFrame? {
// 最小帧:协议头(8) + cmd(2) + flag(2) + 固定数据体(42) + 协议尾(4) = 58 字符
if (hexFrame.length < 58) return null
if (!hexFrame.startsWith(HEADER_HEX) || !hexFrame.endsWith(FOOTER_HEX)) return null
return runCatching {
val bytes = hexFrame.hexToBytes()
val totalLen = bytes.size
val cmd = bytes[ScaleProtocolConstants.HEADER_LEN]
val flag = bytes[ScaleProtocolConstants.HEADER_LEN + 1]
// 固定数据体位于协议尾之前
val fixedBodyStart = totalLen - FOOTER_LEN - FIXED_BODY_LEN
if (fixedBodyStart < BODY_OFFSET) return null
val fixedBody = bytes.copyOfRange(fixedBodyStart, totalLen - FOOTER_LEN)
// 解析固定数据体三个字段
val terminalId = String(fixedBody, 0, TERMINAL_ID_LEN, Charsets.US_ASCII)
val firmwareVersion = String(fixedBody, TERMINAL_ID_LEN, FIRMWARE_VERSION_LEN, Charsets.US_ASCII)
val cmdCount = fixedBody[TERMINAL_ID_LEN + FIRMWARE_VERSION_LEN].toInt() and 0xFF
// 命令专属数据体(固定数据体和协议头尾之间的部分)
val rawBody = bytes.copyOfRange(BODY_OFFSET, fixedBodyStart)
ScaleFrame(
cmd = cmd,
flag = flag,
rawBody = rawBody,
terminalId = terminalId,
firmwareVersion = firmwareVersion,
cmdCount = cmdCount
)
}.getOrNull()
}
// ----------------------------------------------------------------
// 三、数据体解析
// ----------------------------------------------------------------
/**
* 解析心跳数据体(命令 0x01 / 0x81,终端发起)
*
* rawBody 格式:
* [秤数量 1] [秤重量 N×4] [物联卡号 20] [锁1门 1] [锁1锁 1] [锁2门 1] [锁2锁 1] [温度 1]
*
* @param body ScaleFrame.rawBody
* @return 解析成功返回 HeartbeatData;数据不足返回 null
*/
fun parseHeartbeat(body: ByteArray): HeartbeatData? {
if (body.isEmpty()) return null
return runCatching {
var offset = 0
val scaleCount = body[offset++].toInt() and 0xFF
val minBodyLen = 1 + scaleCount * BYTES_PER_WEIGHT + SIM_CARD_NO_LEN + 4 + 1
if (body.size < minBodyLen) return null
val weights = (1..scaleCount).map { index ->
val grams = parseWeightBytes(body, offset)
offset += BYTES_PER_WEIGHT
ScaleWeight(index, grams)
}
// 物联卡号:ASCII字符串,去除尾部空字符
val simCardNo = String(body, offset, SIM_CARD_NO_LEN, Charsets.US_ASCII)
.trimEnd('\u0000').trim()
offset += SIM_CARD_NO_LEN
val lock1Door = body[offset++].toInt() and 0xFF
val lock1Lock = body[offset++].toInt() and 0xFF
val lock2Door = body[offset++].toInt() and 0xFF
val lock2Lock = body[offset++].toInt() and 0xFF
val temperature = body[offset].toInt() and 0xFF
HeartbeatData(
scaleCount = scaleCount,
weights = weights,
simCardNo = simCardNo,
lock1Door = lock1Door,
lock1Lock = lock1Lock,
lock2Door = lock2Door,
lock2Lock = lock2Lock,
temperature = temperature
)
}.getOrNull()
}
/**
* 解析获取状态响应数据体(命令 0x03,终端响应)
*
* rawBody 格式:
* [锁1锁 1] [锁1门 1] [锁2锁 1] [锁2门 1] [秤数量 1] [秤重量 N×4] [温度 1]
*
* @param body ScaleFrame.rawBody
* @return 解析成功返回 StatusData;数据不足返回 null
*/
fun parseStatus(body: ByteArray): StatusData? {
if (body.size < 6) return null
return runCatching {
var offset = 0
val lock1Lock = body[offset++].toInt() and 0xFF
val lock1Door = body[offset++].toInt() and 0xFF
val lock2Lock = body[offset++].toInt() and 0xFF
val lock2Door = body[offset++].toInt() and 0xFF
val scaleCount = body[offset++].toInt() and 0xFF
val minBodyLen = 5 + scaleCount * BYTES_PER_WEIGHT + 1
if (body.size < minBodyLen) return null
val weights = (1..scaleCount).map { index ->
val grams = parseWeightBytes(body, offset)
offset += BYTES_PER_WEIGHT
ScaleWeight(index, grams)
}
val temperature = body[offset].toInt() and 0xFF
StatusData(
lock1Lock = lock1Lock,
lock1Door = lock1Door,
lock2Lock = lock2Lock,
lock2Door = lock2Door,
scaleCount = scaleCount,
weights = weights,
temperature = temperature
)
}.getOrNull()
}
// ----------------------------------------------------------------
// 四、重量解析工具
// ----------------------------------------------------------------
/**
* 解析4字节小端序重量数据
*
* 编码规则(BCD + 小端序 + 最高位符号位):
* - 4字节小端序存储,低字节在前,高字节在后
* - 高字节最高位(bit7)为符号位:0=正数,1=负数
* - 其余位组成8位BCD十进制字符串,作为十进制整数读取,单位为克
*
* 示例:
* [0x13, 0x10, 0x00, 0x00] → 大端序 "00001013" → 十进制 1013g = 1.013KG
* [0x13, 0x01, 0x02, 0x00] → 大端序 "00020113" → 十进制 2113g = 20.113KG(注:原例实为20113g,见下)
* [0x13, 0x10, 0x00, 0x80] → 大端序 "80001013" → 符号位=1 → 十进制 -1013g = -1.013KG
*
* 注意:大端序十六进制字符串按十进制解析(非十六进制),这是BCD编码的关键
*
* @param bytes 字节数组
* @param offset 起始偏移量(连续4字节)
* @return 重量(克),负数表示低于去皮零点
*/
fun parseWeightBytes(bytes: ByteArray, offset: Int): Int {
val b0 = bytes[offset].toInt() and 0xFF // 最低字节
val b1 = bytes[offset + 1].toInt() and 0xFF
val b2 = bytes[offset + 2].toInt() and 0xFF
val b3 = bytes[offset + 3].toInt() and 0xFF // 最高字节(含符号位)
// 检查最高字节的最高位是否为1(负数标志)
val isNegative = (b3 and 0x80) != 0
val b3Cleared = b3 and 0x7F // 清除符号位
// 构造大端序8位十六进制字符串
val hexStr = "%02X%02X%02X%02X".format(b3Cleared, b2, b1, b0)
// 将十六进制字符串作为十进制整数解析(BCD解码)
// "00001013" → toLong() = 1013,而非 toLong(16) = 4115
val absValue = hexStr.toLongOrNull() ?: 0L
return if (isNegative) -absValue.toInt() else absValue.toInt()
}
}
@@ -0,0 +1,614 @@
package com.shuwei.intelligent.shelves.serial
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_ACTIVATE
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_CARD_UPLOAD
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_GET_STATUS
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_HEARTBEAT
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_HEARTBEAT_STORE
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_OPERATION_RECORD
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_POSITION_REPORT
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_TEMP_REPORT
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_TIME_SYNC
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_UNLOCK
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FOOTER_HEX
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.HEADER_HEX
import com.shuwei.intelligent.shelves.utils.hexToBytes
import com.shuwei.intelligent.shelves.utils.toHexString2
import io.github.jeadyx.jserialport.AndroidSerialPort
import io.github.jeadyx.jserialport.SerialPort
import io.github.jeadyx.jserialport.SerialPortFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
import java.util.TimeZone
/**
* 称重控制板管理器(单例入口)
*
* 职责:
* - 管理串口连接生命周期(start/release
* - 通过内部发送队列(Channel)保证指令按顺序写入串口
* - 接收串口原始分片数据,自动拼装帧并分发到对应回调
* - 内置各命令的自动应答逻辑(心跳/对时/卡号等)
* - 提供链式回调注册 和 常用指令构建
*
* 典型用法:
* ```kotlin
* lifecycleScope.launch {
* ScaleManager
* .init(activateCode = ACTIVE_MAP[deviceId] ?: "")
* .onHeartbeat { _, data ->
* data.weights.forEach { Log.d("Scale", "秤${it.index}: ${it.grams}g") }
* }
* .onLog { msg -> Log.d("Scale", msg) }
* .start { data ->
* // 主线程前置钩子,处理看门狗计时等副作用
* }
* }
*
* // 主动发送指令
* ScaleManager.sendCmd(ScaleManager.buildGetStatusCmd())
*
* // 页面销毁时释放(关闭串口 + 清空队列 + 清理回调)
* runBlocking { ScaleManager.release() }
* ```
*/
object ScaleManager {
/** 串口端口名,默认 /dev/ttyS1 */
var portName: String = "/dev/ttyS1"
/** 串口波特率,默认 115200 */
var baudRate: Int = 115200
private val parser = ScaleFrameParser()
private var serialPort: AndroidSerialPort? = null
private var scope = CoroutineScope(Dispatchers.IO)
/** 发送队列:保证串口写入顺序,避免并发写冲突 */
private val sendChannel = Channel<String>(capacity = Channel.UNLIMITED)
/** 当前设备激活码,非空时收到激活请求自动应答 */
private var activateCode: String = ""
/** 心跳应答:心跳上报间隔(秒),默认5秒 */
var heartbeatInterval: Int = 5
/** 心跳应答:开锁后未开门自动落锁时间(秒),默认6秒 */
var lockTimeout: Int = 6
// 各命令监听列表
private val heartbeatListeners = mutableListOf<(ScaleFrame, HeartbeatData) -> Unit>()
private val statusListeners = mutableListOf<(ScaleFrame, StatusData) -> Unit>()
private val lockListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val cardListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val operationListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val timeSyncListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val positionListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val tempReportListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val activateListeners = mutableListOf<(ScaleFrame) -> Unit>()
/** 任意命令的原始帧监听,用于协议中尚未提供专项解析的命令 */
private val rawListeners = mutableMapOf<Byte, MutableList<(ScaleFrame) -> Unit>>()
private val logListeners = mutableListOf<(String) -> Unit>()
// ----------------------------------------------------------------
// 初始化
// ----------------------------------------------------------------
/**
* 初始化,设置设备激活码
*
* @param activateCode 当前设备的激活应答帧十六进制字符串,非空时收到激活请求自动应答
* @return this,支持链式调用
*/
fun init(activateCode: String = ""): ScaleManager {
this.activateCode = activateCode
return this
}
// ----------------------------------------------------------------
// 串口生命周期
// ----------------------------------------------------------------
/**
* 打开串口并启动接收,在回调注册完成后调用(链式末尾)
*
* @param preHook 每帧数据到达时的前置钩子(主线程),用于看门狗计时、UI副作用等
* @return true=串口打开成功并已启动接收,false=串口打开失败
*/
suspend fun start(preHook: ((String) -> Unit)? = null): Boolean {
if (!openPort()) return false
startReceive(preHook)
return true
}
private suspend fun openPort(): Boolean {
if (!scope.isActive) {
scope = CoroutineScope(Dispatchers.IO)
}
return try {
serialPort = SerialPortFactory.create().apply {
open(
portName = portName,
baudRate = baudRate,
dataBits = 8,
stopBits = 1,
parity = SerialPort.PARITY_NONE
)
} as AndroidSerialPort?
// 启动单一消费者协程,顺序消费发送队列
scope.launch {
for (hexStr in sendChannel) {
try {
serialPort?.write(hexStr.hexToBytes())
} catch (e: Exception) {
e.printStackTrace()
log("发送命令失败: ${e.message}")
}
}
}
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
private fun startReceive(preHook: ((String) -> Unit)? = null) {
scope.launch {
while (isActive) {
try {
val flow = serialPort?.read()
flow?.collect { buffer ->
val data = buffer.toHexString2().uppercase(Locale.ROOT)
withContext(Dispatchers.Main) {
try {
preHook?.invoke(data)
onDataReceived(data)
} catch (e: Exception) {
e.printStackTrace()
log("串口数据处理异常: ${e.message}")
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
delay(50)
}
}
}
// ----------------------------------------------------------------
// 数据接收入口
// ----------------------------------------------------------------
/**
* 接收串口原始数据(在串口回调中调用此方法)
*
* 内部自动完成分片拼装 → 帧解析 → 命令分发 → 自动应答
*
* @param hexData 串口收到的十六进制字符串(可能为分片数据)
*/
fun onDataReceived(hexData: String) {
val completeFrame = parser.appendData(hexData) ?: return
val frame = parser.parseFrame(completeFrame) ?: run {
log("帧解析失败,原始数据:$completeFrame")
return
}
dispatch(frame, completeFrame)
}
// ----------------------------------------------------------------
// 回调注册(链式调用)
// ----------------------------------------------------------------
/**
* 注册心跳回调(命令 0x01 / 0x81,终端发起)
* 回调参数:[ScaleFrame 帧信息] [HeartbeatData 心跳数据(含重量、锁状态、温度)]
*/
fun onHeartbeat(listener: (ScaleFrame, HeartbeatData) -> Unit): ScaleManager {
heartbeatListeners.add(listener)
return this
}
/**
* 注册获取状态回调(命令 0x03,服务端请求后终端响应)
* 回调参数:[ScaleFrame 帧信息] [StatusData 状态数据(含重量、锁状态、温度)]
*/
fun onStatus(listener: (ScaleFrame, StatusData) -> Unit): ScaleManager {
statusListeners.add(listener)
return this
}
/**
* 注册开锁反馈回调(命令 0x02,终端发起,关门后上报)
* 回调参数:[ScaleFrame 帧信息],锁状态等信息在 ScaleFrame.rawBody 中
*/
fun onLockFeedback(listener: (ScaleFrame) -> Unit): ScaleManager {
lockListeners.add(listener)
return this
}
/**
* 注册卡号上发回调(命令 0x05,终端发起)
* 回调参数:[ScaleFrame 帧信息],卡号等信息在 ScaleFrame.rawBody 中
*/
fun onCardUpload(listener: (ScaleFrame) -> Unit): ScaleManager {
cardListeners.add(listener)
return this
}
/**
* 注册操作记录上传回调(命令 0x0B,终端发起)
* 回调参数:[ScaleFrame 帧信息],操作记录在 ScaleFrame.rawBody 中
*/
fun onOperationRecord(listener: (ScaleFrame) -> Unit): ScaleManager {
operationListeners.add(listener)
return this
}
/**
* 注册对时回调(命令 0x0C,终端发起)
* 管理器已自动回复当前时间戳,此回调用于业务侧感知对时事件
*/
fun onTimeSync(listener: (ScaleFrame) -> Unit): ScaleManager {
timeSyncListeners.add(listener)
return this
}
/**
* 注册终端位置上报回调(命令 0x0E,终端发起,仅4G版本)
* 回调参数:[ScaleFrame 帧信息],基站信息(MCC/MNC/LAC/CI)在 ScaleFrame.rawBody 中
*/
fun onPositionReport(listener: (ScaleFrame) -> Unit): ScaleManager {
positionListeners.add(listener)
return this
}
/**
* 注册温控信息上报回调(命令 0x10,终端发起)
* 回调参数:[ScaleFrame 帧信息],寄存器内容在 ScaleFrame.rawBody 中
*/
fun onTempReport(listener: (ScaleFrame) -> Unit): ScaleManager {
tempReportListeners.add(listener)
return this
}
/**
* 注册终端激活回调(命令 0x00,终端发起)
* 注意:激活应答(含激活码)需在此回调中手动调用 sendCmd() 发送
*/
fun onActivate(listener: (ScaleFrame) -> Unit): ScaleManager {
activateListeners.add(listener)
return this
}
/**
* 注册任意命令的原始帧监听(用于协议中尚未专项解析的命令,或扩展监听)
*
* @param cmd 命令字节,对应 ScaleProtocolConstants.CMD_* 常量
*/
fun onRawFrame(cmd: Byte, listener: (ScaleFrame) -> Unit): ScaleManager {
rawListeners.getOrPut(cmd) { mutableListOf() }.add(listener)
return this
}
/**
* 注册日志回调,所有内部日志通过此回调输出
*/
fun onLog(listener: (String) -> Unit): ScaleManager {
logListeners.add(listener)
return this
}
// ----------------------------------------------------------------
// 发送指令
// ----------------------------------------------------------------
/**
* 发送十六进制字符串指令,指令进入发送队列按顺序写入串口
*
* @param hexStr 完整帧十六进制字符串,含协议头尾
*/
fun sendCmd(hexStr: String) {
if (!scope.isActive) {
log("警告:ScaleManager 串口未打开或已释放")
return
}
sendChannel.trySend(hexStr)
}
// ----------------------------------------------------------------
// 常用指令构建
// ----------------------------------------------------------------
/**
* 构建获取设备状态指令
* 协议:CMD=03 FLAG=01 [预留12字节]
*/
fun buildGetStatusCmd(): String =
"${HEADER_HEX}0301${"00".repeat(12)}${FOOTER_HEX}"
/**
* 构建开锁/控制指令
*
* @param lock1 锁1操作:1=开 0=关 其他值(如0xFF)=不动作
* @param lock2 锁2操作:1=开 0=关 其他值=不动作
* @param relay1 继电器11=开 0=关 其他值=不动作
* @param relay2 继电器21=开 0=关 其他值=不动作
* @param relay3 继电器31=开 0=关 其他值=不动作
* @param orderId 订单号,超出14字节截断,不足补零
*/
fun buildUnlockCmd(
lock1: Int = 0xFF,
lock2: Int = 0xFF,
relay1: Int = 0xFF,
relay2: Int = 0xFF,
relay3: Int = 0xFF,
orderId: String = ""
): String {
val orderBytes = orderId.toByteArray(Charsets.US_ASCII).copyOf(14)
val orderHex = orderBytes.joinToString("") { "%02X".format(it) }
return "${HEADER_HEX}0201" +
"%02X%02X%02X%02X%02X".format(lock1 and 0xFF, lock2 and 0xFF, relay1 and 0xFF, relay2 and 0xFF, relay3 and 0xFF) +
orderHex +
FOOTER_HEX
}
/**
* 构建开锁1指令(左门)
* @param orderId 订单号,可选
*/
fun buildOpenLock1Cmd(orderId: String = ""): String =
buildUnlockCmd(lock1 = 1, lock2 = 0xFF, relay1 = 0xFF, relay2 = 0xFF, relay3 = 0xFF, orderId = orderId)
/**
* 构建开锁2指令(右门)
* @param orderId 订单号,可选
*/
fun buildOpenLock2Cmd(orderId: String = ""): String =
buildUnlockCmd(lock1 = 0xFF, lock2 = 1, relay1 = 0xFF, relay2 = 0xFF, relay3 = 0xFF, orderId = orderId)
/**
* 构建秤置零指令
* 协议:CMD=04 FLAG=01 [秤号1字节]
*
* @param scaleIndex 秤号,从1开始
*/
fun buildScaleZeroCmd(scaleIndex: Int): String =
"${HEADER_HEX}0401%02X${FOOTER_HEX}".format(scaleIndex and 0xFF)
/**
* 构建秤标定指令
* 协议:CMD=06 FLAG=01 [秤号1字节] [砝码值4字节小端序]
*
* @param scaleIndex 秤号,从1开始
* @param weightGrams 砝码重量(克),如10kg砝码传入10000
*/
fun buildScaleCalibrateCmd(scaleIndex: Int, weightGrams: Int): String {
// 砝码值转小端序4字节(BCD编码:克数按十进制写入十六进制位)
val weightHex = "%08d".format(weightGrams) // BCD:十进制值直接作为十六进制字符串
val leHex = weightHex.chunked(2).reversed().joinToString("")
return "${HEADER_HEX}0601%02X${leHex}${FOOTER_HEX}".format(scaleIndex and 0xFF)
}
/**
* 构建温控器控制指令
* 协议:CMD=0F FLAG=01 [操作编码1字节] [设定值2字节]
*
* @param opCode 操作编码(参考温控器说明书)
* @param setValue 设定值
*/
fun buildTempCtrlCmd(opCode: Int, setValue: Int): String =
"${HEADER_HEX}0F01%02X%04X${FOOTER_HEX}".format(opCode and 0xFF, setValue and 0xFFFF)
/**
* 构建临时参数设置指令
* 协议:CMD=11 FLAG=01 [参数编码1字节] [设定值1字节]
*
* @param paramCode 参数编码(1=关门语音触发条件 2=未关门语音提示 3=锁常开设置)
* @param value 设定值
*/
fun buildTempParamSetCmd(paramCode: Int, value: Int): String =
"${HEADER_HEX}1101%02X%02X${FOOTER_HEX}".format(paramCode and 0xFF, value and 0xFF)
// ----------------------------------------------------------------
// 资源释放
// ----------------------------------------------------------------
/**
* 释放所有资源:清空回调列表、发送函数及帧缓冲
* 通常在 Activity/Fragment 销毁或不再使用时调用
*/
suspend fun release() {
sendChannel.close()
scope.cancel()
serialPort?.close()
serialPort = null
heartbeatListeners.clear()
statusListeners.clear()
lockListeners.clear()
cardListeners.clear()
operationListeners.clear()
timeSyncListeners.clear()
positionListeners.clear()
tempReportListeners.clear()
activateListeners.clear()
rawListeners.clear()
logListeners.clear()
activateCode = ""
parser.reset()
}
// ----------------------------------------------------------------
// 内部命令分发与自动应答
// ----------------------------------------------------------------
private fun dispatch(frame: ScaleFrame, rawHex: String) {
when (frame.cmd) {
CMD_ACTIVATE -> handleActivate(frame, rawHex)
CMD_HEARTBEAT,
CMD_HEARTBEAT_STORE -> handleHeartbeat(frame, rawHex)
CMD_UNLOCK -> handleLockFeedback(frame, rawHex)
CMD_GET_STATUS -> handleStatus(frame, rawHex)
CMD_CARD_UPLOAD -> handleCardUpload(frame, rawHex)
CMD_OPERATION_RECORD -> handleOperationRecord(frame, rawHex)
CMD_TIME_SYNC -> handleTimeSync(frame, rawHex)
CMD_POSITION_REPORT -> handlePositionReport(frame, rawHex)
CMD_TEMP_REPORT -> handleTempReport(frame, rawHex)
else -> {
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到未处理命令:0x%02X,终端ID:${frame.terminalId},原始数据:$rawHex".format(frame.cmd))
}
}
}
/**
* 处理终端激活(CMD=0x00
* 若 activateMap 中存在当前 deviceId 对应的激活码,则自动应答;
* 否则仍回调 onActivate,由业务方自行处理
*/
private fun handleActivate(frame: ScaleFrame, rawHex: String) {
log("收到[终端激活]指令,终端ID${frame.terminalId},固件版本:${frame.firmwareVersion},原始数据:$rawHex")
val activeCmd = activateCode
if (!activeCmd.isNullOrBlank()) {
sendCmd(activeCmd)
log("激活码自动应答:$activeCmd")
}
activateListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
/**
* 处理心跳(CMD=0x01 / 0x81
* 自动应答:[心跳间隔] [上锁时间] [设备固件版本5字节]
*/
private fun handleHeartbeat(frame: ScaleFrame, rawHex: String) {
val firmwareHex = frame.firmwareVersion.toAsciiHex()
val resp = "${HEADER_HEX}0102%02X%02X${firmwareHex}${FOOTER_HEX}".format(
heartbeatInterval and 0xFF,
lockTimeout and 0xFF
)
sendCmd(resp)
val data = parser.parseHeartbeat(frame.rawBody)
if (data != null) {
heartbeatListeners.forEach { it(frame, data) }
}
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[心跳]指令,终端ID${frame.terminalId},秤数量:${data?.scaleCount ?: 0},原始数据:$rawHex")
}
/**
* 处理开锁反馈(CMD=0x02,终端发起,关门后上报)
* 自动应答:[锁号00] [终端ID 15字节] [固件版本 5字节]
*/
private fun handleLockFeedback(frame: ScaleFrame, rawHex: String) {
val terminalIdHex = frame.terminalId.toAsciiHex()
val firmwareHex = frame.firmwareVersion.toAsciiHex()
val resp = "${HEADER_HEX}020200${terminalIdHex}${firmwareHex}${FOOTER_HEX}"
sendCmd(resp)
lockListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[开锁反馈]指令,终端ID${frame.terminalId},原始数据:$rawHex")
}
/**
* 处理获取状态响应(CMD=0x03,终端响应)
* 无需应答,直接解析并分发
*/
private fun handleStatus(frame: ScaleFrame, rawHex: String) {
val data = parser.parseStatus(frame.rawBody)
if (data != null) {
statusListeners.forEach { it(frame, data) }
}
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[获取状态]响应,终端ID${frame.terminalId},秤数量:${data?.scaleCount ?: 0},原始数据:$rawHex")
}
/**
* 处理卡号上发(CMD=0x05,终端发起)
* 自动应答:成功 0x03
*/
private fun handleCardUpload(frame: ScaleFrame, rawHex: String) {
val resp = "${HEADER_HEX}050203${FOOTER_HEX}"
sendCmd(resp)
cardListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[卡号上发]指令,终端ID${frame.terminalId},原始数据:$rawHex")
}
/**
* 处理操作记录上传(CMD=0x0B,终端发起)
* 自动应答:成功 0x03
*/
private fun handleOperationRecord(frame: ScaleFrame, rawHex: String) {
val resp = "${HEADER_HEX}0B0203${FOOTER_HEX}"
sendCmd(resp)
operationListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[操作记录上传]指令,终端ID${frame.terminalId},原始数据:$rawHex")
}
/**
* 处理对时请求(CMD=0x0C,终端发起,仅以太网版本)
* 自动应答:当前 Unix 时间戳(含时区偏移),小端序4字节
*/
private fun handleTimeSync(frame: ScaleFrame, rawHex: String) {
val nowMs = System.currentTimeMillis()
val timeSec = nowMs / 1000 + TimeZone.getDefault().getOffset(nowMs) / 1000
// 取低32位,转为小端序4字节十六进制
val timeHex = "%08X".format(timeSec.toInt())
.chunked(2).reversed().joinToString("")
val resp = "${HEADER_HEX}0C02${timeHex}${FOOTER_HEX}"
sendCmd(resp)
timeSyncListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[对时]请求,终端ID${frame.terminalId},时间戳:${timeSec},应答:$resp")
}
/**
* 处理终端位置上报(CMD=0x0E,终端发起,仅4G版本)
* 自动应答:保留字节 0x00
*/
private fun handlePositionReport(frame: ScaleFrame, rawHex: String) {
val resp = "${HEADER_HEX}0E0200${FOOTER_HEX}"
sendCmd(resp)
positionListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[终端位置上报]指令,终端ID${frame.terminalId},原始数据:$rawHex")
}
/**
* 处理温控信息上报(CMD=0x10,终端发起)
* 自动应答:保留字节 0x00
*/
private fun handleTempReport(frame: ScaleFrame, rawHex: String) {
val resp = "${HEADER_HEX}100200${FOOTER_HEX}"
sendCmd(resp)
tempReportListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[温控信息上报]指令,终端ID${frame.terminalId},原始数据:$rawHex")
}
// ----------------------------------------------------------------
// 私有工具
// ----------------------------------------------------------------
private fun log(message: String) {
logListeners.forEach { it(message) }
}
/**
* 将 ASCII 字符串转为十六进制字符串,用于指令构造
* 例:"V2.10" → "56322E3130"
*/
private fun String.toAsciiHex(): String =
toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
}
@@ -0,0 +1,121 @@
package com.shuwei.intelligent.shelves.serial
/**
* 称重控制板接口协议常量
* 统一管理协议头尾、所有命令字节及帧结构偏移量
* 设计为独立常量对象,不依赖任何业务类,可复制到其他项目直接使用
*/
object ScaleProtocolConstants {
// ----------------------------------------------------------------
// 协议头尾
// ----------------------------------------------------------------
/** 协议头字节数组 */
val HEADER: ByteArray = byteArrayOf(0xD6.toByte(), 0xDA.toByte(), 0xDE.toByte(), 0xDB.toByte())
/** 协议头十六进制字符串 */
const val HEADER_HEX: String = "D6DADEDB"
/** 协议尾字节数组 */
val FOOTER: ByteArray = byteArrayOf(0xFF.toByte(), 0xFF.toByte())
/** 协议尾十六进制字符串 */
const val FOOTER_HEX: String = "FFFF"
// ----------------------------------------------------------------
// 命令字节(cmd 字段,1字节)
// 发起方说明:终端=秤控制板主动上报;服务端=Android端主动发送
// ----------------------------------------------------------------
/** 0x00 终端激活(终端发起) */
const val CMD_ACTIVATE: Byte = 0x00
/** 0x01 心跳(终端发起) */
const val CMD_HEARTBEAT: Byte = 0x01
/** 0x81 心跳(终端发起,需存储串口屏显示信息) */
const val CMD_HEARTBEAT_STORE: Byte = 0x81.toByte()
/** 0x02 开锁(服务端发起) */
const val CMD_UNLOCK: Byte = 0x02
/** 0x03 获取状态(服务端发起) */
const val CMD_GET_STATUS: Byte = 0x03
/** 0x04 秤置零(服务端发起) */
const val CMD_SCALE_ZERO: Byte = 0x04
/** 0x05 卡号上发(终端发起) */
const val CMD_CARD_UPLOAD: Byte = 0x05
/** 0x06 秤标定(服务端发起) */
const val CMD_SCALE_CALIBRATE: Byte = 0x06
/** 0x07 价签设置(服务端发起) */
const val CMD_PRICE_TAG_SET: Byte = 0x07
/** 0x08 串口屏(服务端发起) */
const val CMD_SERIAL_SCREEN: Byte = 0x08
/** 0x09 语音播报(服务端发起) */
const val CMD_VOICE_BROADCAST: Byte = 0x09
/** 0x0A 价签(第二块串口屏/透传)设置(服务端发起) */
const val CMD_PRICE_TAG_SET2: Byte = 0x0A
/** 0x0B 操作记录上传(终端发起) */
const val CMD_OPERATION_RECORD: Byte = 0x0B
/** 0x0C 对时(终端发起) */
const val CMD_TIME_SYNC: Byte = 0x0C
/** 0x0E 终端位置上报(终端发起) */
const val CMD_POSITION_REPORT: Byte = 0x0E
/** 0x0F 温控器控制(服务端发起) */
const val CMD_TEMP_CTRL: Byte = 0x0F
/** 0x10 温控信息上报(终端发起) */
const val CMD_TEMP_REPORT: Byte = 0x10
/** 0x11 临时参数设置(服务端发起) */
const val CMD_TEMP_PARAM_SET: Byte = 0x11
// ----------------------------------------------------------------
// 帧结构固定偏移量(单位:字节)
// ----------------------------------------------------------------
/** 协议头长度 */
const val HEADER_LEN: Int = 4
/** 命令字段长度 */
const val CMD_LEN: Int = 1
/** 标志字段长度 */
const val FLAG_LEN: Int = 1
/** 数据体起始偏移(协议头 + 命令 + 标志) */
const val BODY_OFFSET: Int = HEADER_LEN + CMD_LEN + FLAG_LEN
/** 协议尾长度 */
const val FOOTER_LEN: Int = 2
/** 单台秤重量字节数 */
const val BYTES_PER_WEIGHT: Int = 4
/** 物联卡号字节数 */
const val SIM_CARD_NO_LEN: Int = 20
/** 固定数据体:终端ID字节数 */
const val TERMINAL_ID_LEN: Int = 15
/** 固定数据体:固件版本字节数 */
const val FIRMWARE_VERSION_LEN: Int = 5
/** 固定数据体:命令次数字节数 */
const val CMD_COUNT_LEN: Int = 1
/** 固定数据体总长度(终端ID + 固件版本 + 命令次数) */
const val FIXED_BODY_LEN: Int = TERMINAL_ID_LEN + FIRMWARE_VERSION_LEN + CMD_COUNT_LEN
}
@@ -0,0 +1,15 @@
package com.shuwei.intelligent.shelves.serial
/**
* 单台秤的重量数据
*
* @param index 秤的编号,从1开始
* @param grams 重量,单位克,负数表示去皮/负重
*/
data class ScaleWeight(
val index: Int,
val grams: Int
) {
/** 重量转换为千克,保留3位小数 */
val kg: Double get() = grams / 1000.0
}
@@ -1,78 +0,0 @@
package com.shuwei.intelligent.shelves.serial
import android.annotation.SuppressLint
import androidx.lifecycle.lifecycleScope
import com.shuwei.intelligent.shelves.utils.hexToBytes
import com.shuwei.intelligent.shelves.utils.toHexString2
import io.github.jeadyx.jserialport.AndroidSerialPort
import io.github.jeadyx.jserialport.SerialPort
import io.github.jeadyx.jserialport.SerialPortFactory
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import java.nio.charset.Charset
import java.util.Locale
object SerialPortManager {
private const val portName: String = "/dev/ttyS1"
private const val baudRate: Int = 115200
private var serialPort: AndroidSerialPort? = null
// private var serialPort: SerialPort? = null
private val scope = CoroutineScope(Dispatchers.IO)
// 打开串口
suspend fun open(): Boolean {
return try {
serialPort = SerialPortFactory.create().apply {
open(
portName = portName,
baudRate = baudRate,
dataBits = 8,
stopBits = 1,
parity = SerialPort.PARITY_NONE
)
} as AndroidSerialPort?
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// 发送数据
suspend fun send(data: String): Boolean {
return try {
serialPort?.write(data.hexToBytes())
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// 启动接收协程
@Suppress("DEPRECATION")
@SuppressLint("DefaultLocale")
fun startReceive(callback: (String) -> Unit) {
scope.launch {
while (isActive) {
try {
val flow = serialPort?.read()
flow?.collect { buffer ->
val data = buffer.toHexString2().toUpperCase()
callback(data)
}
} catch (e: Exception) {
e.printStackTrace()
}
delay(50)
}
}
}
// 关闭串口
suspend fun close() {
scope.cancel()
serialPort?.close()
}
}
@@ -0,0 +1,22 @@
package com.shuwei.intelligent.shelves.serial
/**
* 获取设备状态数据(命令 0x03,服务端发起后终端响应)
*
* @param lock1Lock 锁1锁状态:0-关 1-开
* @param lock1Door 锁1门状态:0-关 1-开
* @param lock2Lock 锁2锁状态:0-关 1-开
* @param lock2Door 锁2门状态:0-关 1-开
* @param scaleCount 秤的数量
* @param weights 各台秤的重量列表,索引从1开始
* @param temperature 温度值(原始字节,单位由温控器决定)
*/
data class StatusData(
val lock1Lock: Int,
val lock1Door: Int,
val lock2Lock: Int,
val lock2Door: Int,
val scaleCount: Int,
val weights: List<ScaleWeight>,
val temperature: Int
)
@@ -39,11 +39,19 @@ class CrashHandler private constructor(private val context: Context) :
synchronized(CrashHandler::class.java) {
if (instance == null) {
instance = CrashHandler(context.applicationContext)
// 初始化时清理过期的崩溃日志(默认保留7天)
instance?.cleanupOldCrashReports()
}
}
}
}
/**
* 获取单例实例
* @return 已通过 init() 初始化时返回实例,否则返回 null
*/
fun getInstance(): CrashHandler? = instance
//fun getCrashReportFiles(context: Context): Array<File> {
// val crashDir = getCrashDir()
// return if (crashDir.exists() && crashDir.isDirectory) {
@@ -222,16 +230,21 @@ class CrashHandler private constructor(private val context: Context) :
* 清理旧的崩溃日志
*/
fun cleanupOldCrashReports(maxAgeDays: Int = 7) {
val crashDir = getCrashDir()
if (!crashDir.exists() || !crashDir.isDirectory) return
// 该方法会在 Application 启动及定时任务中调用,异常必须内部消化,避免影响主流程
try {
val crashDir = getCrashDir()
if (!crashDir.exists() || !crashDir.isDirectory) return
val now = System.currentTimeMillis()
val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L
val now = System.currentTimeMillis()
val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L
crashDir.listFiles()?.forEach { file ->
if (file.lastModified() < now - maxAgeMillis) {
file.delete()
crashDir.listFiles()?.forEach { file ->
if (file.lastModified() < now - maxAgeMillis) {
file.delete()
}
}
} catch (e: Exception) {
Log.e(TAG, "清理崩溃日志失败", e)
}
}
@@ -17,8 +17,37 @@ class FileLogger(private val context: Context) {
private const val LOG_DIR = "logs"
private const val FILE_PREFIX = "log_"
private const val FILE_EXTENSION = ".txt"
/** 日志保留天数,超过该天数的日志文件在应用启动时清理 */
private const val RETENTION_DAYS = 7L
/** 单个日志文件大小上限(100MB),超过后当天不再写入,防止异常刷屏撑爆存储 */
private const val MAX_LOG_FILE_SIZE = 100L * 1024 * 1024
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
private val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
/**
* 清理过期的日志文件,保留最近 retentionDays 天
* 按文件最后修改时间判断,最近仍在写入的文件不会被误删
* 建议在 Application 启动时调用一次
* @param context 上下文,用于定位 filesDir
* @param retentionDays 日志保留天数
*/
fun cleanupExpiredLogs(context: Context, retentionDays: Long = RETENTION_DAYS) {
try {
val cutoff = System.currentTimeMillis() - retentionDays * 24 * 60 * 60 * 1000L
context.filesDir.listFiles { _, name ->
name.startsWith(FILE_PREFIX) && name.endsWith(FILE_EXTENSION)
}?.forEach { file ->
if (file.lastModified() < cutoff) {
file.delete()
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
/**
@@ -45,6 +74,7 @@ class FileLogger(private val context: Context) {
/**
* 写入文件
* 单文件超过大小上限(100MB)时当天不再写入,防止异常刷屏撑爆存储
*/
private fun writeToFile(logEntry: String) {
// val path = File(context.filesDir, LOG_DIR)
@@ -52,10 +82,10 @@ class FileLogger(private val context: Context) {
// path.mkdirs()
// }
val fileName = getCurrentDateFileName()
// val file = File(path, fileName)
// if (file.exists().not()) {
// file.createNewFile()
// }
val file = File(context.filesDir, fileName)
if (file.exists() && file.length() > MAX_LOG_FILE_SIZE) {
return
}
val output = context.openFileOutput(fileName, Context.MODE_APPEND)
val writer = BufferedWriter(OutputStreamWriter(output))
@@ -0,0 +1,79 @@
package com.shuwei.intelligent.shelves.utils
import android.content.Context
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.model.ShelfModelV2
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
object GridLayoutTool {
/**
* 设备2列,10格
*/
private val device2Columns = ProtocolConstants.device2Columns
/**
* 设备3列,15格,左侧1列竖向编号1、2、3、4、5,右侧2列5行编号为6-7、8-9、10-11、12-13、14-15
*/
private val device3Columns = ProtocolConstants.device3Columns
fun getLayoutManager(context: Context): RecyclerView.LayoutManager? {
return if (device2Columns.contains(App.deviceId)) {
GridLayoutManager(context, 2, GridLayoutManager.VERTICAL, false)
} else if (device3Columns.contains(App.deviceId)) {
GridLayoutManager(context, 3, GridLayoutManager.VERTICAL, false)
} else {
null
}
}
fun getLayoutManagerV2(context: Context, columns: Int) = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, false)
/**
* 初始化list显示顺序
*/
fun initListSort(source: List<ShelfModel>): List<ShelfModel> {
val map = source.associateBy { it.deviceNo }
return if (device3Columns.contains(App.deviceId)) {
// 按全局定义的显示顺序取对应数据,找不到则跳过
ProtocolConstants.device3ColumnsOrder.mapNotNull { map[it] }
} else if (device2Columns.contains(App.deviceId)) {
ProtocolConstants.device2ColumnsOrder.mapNotNull { map[it] }
} else {
source
}
}
fun initListSortV2(rows:Int, columns:Int, source: List<ShelfModelV2>): List<ShelfModelV2> {
val map = source.associateBy { it.slotNo?.toIntOrNull()?:0 }
if (rows != 5) return source
return when (columns) {
3 -> {
// 3列顺序
ProtocolConstants.device3ColumnsOrder.mapNotNull { map[it] }
}
2 -> {
// 2列顺序
ProtocolConstants.device2ColumnsOrder.mapNotNull { map[it] }
}
else -> {
//没有则默认
source
}
}
}
fun submitListSortV2(source: List<ShelfModelV2>): List<ShelfModelV2> {
return source.sortedBy { it.slotNo?.toIntOrNull()?:0 }
}
/**
* 提交接口list顺序
*/
fun submitListSort(source: List<ShelfModel>): List<ShelfModel> {
return source.sortedBy { it.deviceNo }
}
}
@@ -1,6 +1,7 @@
package com.shuwei.intelligent.shelves.utils
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.GlobalKey
import com.shuwei.intelligent.shelves.utils.ext.put
import kotlin.to
@@ -28,4 +29,10 @@ object SpTool {
fun getBoolean(key: String, defValue: Boolean = false): Boolean {
return pref.getBoolean(key, defValue)
}
var baseUrl: String
get() = getString(GlobalKey.KEY_BASE_URL, "")
set(value) {
put(GlobalKey.KEY_BASE_URL, value)
}
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#30000000">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/white" />
<corners android:radius="8dp" />
<stroke
android:width="1dp"
android:color="@color/bg_card_blue" />
</shape>
</item>
</ripple>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#30000000">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/bg_card_blue" />
<corners android:radius="8dp" />
</shape>
</item>
</ripple>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/white"/>
<corners android:radius="12dp"/>
</shape>
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="240dp"
android:height="240dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<!-- 由小到大排列的圆点,构成旋转加载指示器 -->
<path
android:fillColor="#ffffff"
android:pathData="M876.864,782.592c3.264,0 6.272,-3.2 6.272,-6.656c0,-3.456 -3.008,-6.592 -6.272,-6.592c-3.264,0 -6.272,3.2 -6.272,6.592c0,3.456 3.008,6.656 6.272,6.656z" />
<path
android:fillColor="#ffffff"
android:pathData="M736.32,935.936c2.304,2.432 5.568,3.84 8.768,3.84a12.16,12.16 0,0 0,8.832 -3.84a13.76,13.76 0,0 0,0 -18.56a12.224,12.224 0,0 0,-8.832 -3.84a12.16,12.16 0,0 0,-8.768 3.84a13.696,13.696 0,0 0,0 18.56z" />
<path
android:fillColor="#ffffff"
android:pathData="M552.32,1018.24c3.456,3.648 8.32,5.76 13.184,5.76a18.368,18.368 0,0 0,13.184 -5.76a20.608,20.608 0,0 0,0 -27.968a18.368,18.368 0,0 0,-13.184 -5.824a18.368,18.368 0,0 0,-13.184 5.76a20.608,20.608 0,0 0,0 28.032z" />
<path
android:fillColor="#ffffff"
android:pathData="M353.984,1012.48c4.608,4.8 11.072,7.68 17.6,7.68a24.448,24.448 0,0 0,17.536 -7.68a27.456,27.456 0,0 0,0 -37.248a24.448,24.448 0,0 0,-17.536 -7.68a24.448,24.448 0,0 0,-17.6 7.68a27.52,27.52 0,0 0,0 37.184z" />
<path
android:fillColor="#ffffff"
android:pathData="M178.304,920.64c5.76,6.08 13.824,9.6 21.952,9.6a30.592,30.592 0,0 0,22.016 -9.6a34.368,34.368 0,0 0,0 -46.592a30.592,30.592 0,0 0,-22.016 -9.6a30.592,30.592 0,0 0,-21.952 9.6a34.368,34.368 0,0 0,0 46.592z" />
<path
android:fillColor="#ffffff"
android:pathData="M57.152,761.28c6.912,7.36 16.64,11.648 26.368,11.648a36.736,36.736 0,0 0,26.432 -11.584a41.28,41.28 0,0 0,0 -55.936a36.736,36.736 0,0 0,-26.432 -11.584a36.8,36.8 0,0 0,-26.368 11.52a41.28,41.28 0,0 0,0 56z" />
<path
android:fillColor="#ffffff"
android:pathData="M12.736,564.672a42.88,42.88 0,0 0,30.784 13.44a42.88,42.88 0,0 0,30.784 -13.44a48.128,48.128 0,0 0,0 -65.216a42.88,42.88 0,0 0,-30.72 -13.44a42.88,42.88 0,0 0,-30.848 13.44a48.128,48.128 0,0 0,0 65.216z" />
<path
android:fillColor="#ffffff"
android:pathData="M52.544,369.28a48.96,48.96 0,0 0,35.2 15.36a48.96,48.96 0,0 0,35.2 -15.36a54.976,54.976 0,0 0,0 -74.56a48.96,48.96 0,0 0,-35.2 -15.424a48.96,48.96 0,0 0,-35.2 15.424a54.976,54.976 0,0 0,0 74.56z" />
<path
android:fillColor="#ffffff"
android:pathData="M168.32,212.48c10.368,11.008 24.96,17.408 39.68,17.408c14.592,0 29.184,-6.4 39.552,-17.408a61.888,61.888 0,0 0,0 -83.84a55.104,55.104 0,0 0,-39.616 -17.408c-14.656,0 -29.248,6.4 -39.616,17.408a61.888,61.888 0,0 0,0 83.84z" />
<path
android:fillColor="#ffffff"
android:pathData="M337.344,124.8c11.52,12.16 27.712,19.264 43.968,19.264c16.256,0 32.448,-7.04 43.968,-19.264a68.672,68.672 0,0 0,0 -93.184a61.248,61.248 0,0 0,-43.968 -19.264a61.248,61.248 0,0 0,-43.968 19.264a68.736,68.736 0,0 0,0 93.184z" />
<path
android:fillColor="#ffffff"
android:pathData="M526.976,123.712c12.672,13.44 30.528,21.248 48.448,21.248s35.712,-7.808 48.384,-21.248a75.584,75.584 0,0 0,0 -102.464A67.392,67.392 0,0 0,575.36 0c-17.92,0 -35.776,7.808 -48.448,21.248a75.584,75.584 0,0 0,0 102.464z" />
<path
android:fillColor="#ffffff"
android:pathData="M700.8,210.304c13.824,14.592 33.28,23.104 52.736,23.104c19.584,0 39.04,-8.512 52.8,-23.104a82.432,82.432 0,0 0,0 -111.744a73.472,73.472 0,0 0,-52.8 -23.168c-19.52,0 -38.912,8.512 -52.736,23.168a82.432,82.432 0,0 0,0 111.744z" />
<path
android:fillColor="#ffffff"
android:pathData="M824.832,368.832c14.976,15.872 36.032,25.088 57.216,25.088c21.12,0 42.24,-9.216 57.152,-25.088a89.344,89.344 0,0 0,0 -121.088a79.616,79.616 0,0 0,-57.152 -25.088c-21.184,0 -42.24,9.216 -57.216,25.088a89.344,89.344 0,0 0,0 121.088z" />
<path
android:fillColor="#ffffff"
android:pathData="M875.264,572.864c16.128,17.088 38.784,27.008 61.632,27.008c22.784,0 45.44,-9.92 61.568,-27.008a96.256,96.256 0,0 0,0 -130.432a85.76,85.76 0,0 0,-61.568 -27.072c-22.848,0 -45.44,9.984 -61.632,27.072a96.192,96.192 0,0 0,0 130.432z" />
</vector>
+1 -1
View File
@@ -23,7 +23,7 @@
android:layout_gravity="start"
android:textSize="24sp"
android:fontFamily="sans-serif-medium"
tools:text="1-1冷藏柜 -2°C / 87%" />
android:text=" " />
<TextView
android:id="@+id/tv_right_status"
+6 -6
View File
@@ -73,9 +73,9 @@
<!-- 加载动画 -->
<ImageView
android:id="@+id/ivLoading"
android:layout_width="160dp"
android:layout_height="160dp"
android:src="@drawable/loading_spinner"
android:layout_width="120dp"
android:layout_height="120dp"
android:src="@drawable/loading"
android:contentDescription="加载中" />
<!-- 倒计时文字 -->
@@ -85,7 +85,7 @@
android:layout_height="wrap_content"
android:text="60秒"
android:textSize="40sp"
android:textColor="@color/black"
android:textColor="@color/white"
android:textStyle="bold"
android:layout_marginTop="50dp" />
@@ -95,7 +95,7 @@
android:layout_height="wrap_content"
android:text="网络连接检测中,请稍后……"
android:textSize="22sp"
android:textColor="#555555"
android:textColor="@color/white"
android:layout_marginTop="20dp" />
</LinearLayout>
@@ -123,7 +123,7 @@
android:layout_height="wrap_content"
android:text="网络连接失败"
android:textSize="36sp"
android:textColor="@color/black"
android:textColor="@color/white"
android:textStyle="bold"
android:layout_marginBottom="40dp" />
+54 -2
View File
@@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
@@ -39,8 +44,55 @@
android:text="查找1001指令"/>
</FrameLayout>
<EditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:background="@android:color/white"
android:hint="搜索日志..."
android:paddingHorizontal="12dp"
android:textSize="14sp"
android:singleLine="true"
tools:ignore="Autofill,TextFields" />
<HorizontalScrollView
android:id="@+id/hsvTimeNav"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="8dp"
android:scrollbars="none">
<LinearLayout
android:id="@+id/llTimeNav"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:paddingHorizontal="12dp" />
</HorizontalScrollView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvLogList"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabJump"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:src="@android:drawable/ic_menu_sort_by_size"
app:fabSize="normal"
tools:ignore="ContentDescription" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
@@ -0,0 +1,311 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/bg_page">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="376dp"
android:layout_marginStart="24dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="24dp"
app:cardBackgroundColor="@color/white"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/tvShelfName"
android:layout_width="wrap_content"
android:layout_height="60dp"
android:layout_marginTop="24dp"
android:fontFamily="sans-serif-medium"
android:gravity="center"
android:textColor="@color/bg_page"
android:textSize="30sp"
tools:text="货架 - 03" />
<Space
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tvFoodWeight"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/food_weight_orange"
android:textSize="60sp"
android:textStyle="bold"
tools:text="1506克" />
<Space
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tvFoodName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/food_name_black"
android:textSize="36sp"
android:textStyle="bold"
tools:text="金针菇" />
<Space
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnClearZero"
android:layout_width="120dp"
android:layout_height="60dp"
android:layout_gravity="top|end"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:backgroundTint="@color/bg_page"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:insetTop="0dp"
android:insetBottom="0dp"
android:maxLines="1"
android:text="清零"
android:textColor="@color/white"
android:textSize="30sp"
app:cornerRadius="12dp"
app:elevation="0dp"
tools:ignore="HardcodedText" />
<ImageView
android:id="@+id/ivBack"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_gravity="top|start"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:src="@drawable/ic_back_512"
tools:ignore="ContentDescription" />
</FrameLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="24dp"
app:cardBackgroundColor="@color/white"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 类型单选:净菜包 / 餐品净菜包 -->
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroupType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
android:layout_marginTop="36dp"
android:layout_marginEnd="48dp"
app:selectionRequired="true"
android:layout_gravity="center_horizontal"
app:singleSelection="true">
<com.google.android.material.chip.Chip
android:id="@+id/chipClean"
style="@style/Widget.MaterialComponents.Chip.Choice"
android:layout_width="180dp"
android:layout_height="64dp"
android:checked="true"
android:textAlignment="center"
android:fontFamily="sans-serif-medium"
android:text="净菜包"
android:textSize="24sp"
app:chipMinHeight="64dp"
tools:ignore="HardcodedText" />
<com.google.android.material.chip.Chip
android:id="@+id/chipMeal"
style="@style/Widget.MaterialComponents.Chip.Choice"
android:layout_width="180dp"
android:layout_height="64dp"
android:layout_marginStart="50dp"
android:textAlignment="center"
android:fontFamily="sans-serif-medium"
android:text="餐品净菜包"
android:textSize="24sp"
app:chipMinHeight="64dp"
tools:ignore="HardcodedText" />
</com.google.android.material.chip.ChipGroup>
<!-- 搜索框 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_marginStart="48dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="48dp"
android:background="@drawable/shape_search"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:src="@drawable/ic_search_food"
tools:ignore="ContentDescription" />
<EditText
android:id="@+id/etInputFood"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginEnd="24dp"
android:background="@null"
android:fontFamily="sans-serif-medium"
android:hint="输入名称搜索"
android:imeOptions="actionSearch"
android:inputType="text"
android:maxLines="1"
android:paddingStart="1dp"
android:paddingEnd="1dp"
android:text=""
android:textColor="@color/food_name_black"
android:textColorHint="#B4BEC8"
android:textSize="30sp"
tools:ignore="Autofill,HardcodedText,TextFields" />
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:layout_weight="1">
<com.scwang.smart.refresh.layout.SmartRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.scwang.smart.refresh.header.ClassicsHeader
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSearch"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingStart="36dp"
android:paddingTop="12dp"
android:paddingEnd="36dp"
android:paddingBottom="12dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:itemCount="10"
tools:listitem="@layout/list_item_search" />
<com.scwang.smart.refresh.footer.ClassicsFooter
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.scwang.smart.refresh.layout.SmartRefreshLayout>
<include
android:id="@+id/include"
layout="@layout/layout_empty_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnClearEmpty"
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_marginStart="48dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="24dp"
android:layout_weight="1"
android:backgroundTint="@color/bg_page"
android:ellipsize="end"
android:insetTop="0dp"
android:insetBottom="0dp"
android:maxLines="1"
android:text="清空"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold"
app:cornerRadius="12dp"
app:elevation="0dp"
tools:ignore="HardcodedText" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnConfirm"
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="48dp"
android:layout_marginBottom="24dp"
android:layout_weight="1"
android:backgroundTint="@color/bg_page"
android:ellipsize="end"
android:insetTop="0dp"
android:insetBottom="0dp"
android:maxLines="1"
android:text="确定"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold"
app:cornerRadius="12dp"
app:elevation="0dp"
tools:ignore="HardcodedText" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="600dp"
android:layout_height="wrap_content"
android:background="@drawable/bg_white_radius12"
android:orientation="vertical"
android:paddingHorizontal="40dp"
android:paddingVertical="36dp">
<!-- 标题 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="环境切换"
android:textColor="#ff141428"
android:textSize="36sp"
android:textStyle="bold" />
<!-- 分割线 -->
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="24dp"
android:background="#FFDCDCF0" />
<!-- 环境选项 -->
<RadioGroup
android:id="@+id/rgEnv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:orientation="vertical">
<RadioButton
android:id="@+id/rbTest"
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:text="本地环境"
android:textColor="#ff141428"
android:textSize="28sp" />
<RadioButton
android:id="@+id/rbUat"
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:text="测试环境"
android:textColor="#ff141428"
android:textSize="28sp" />
<RadioButton
android:id="@+id/rbProd"
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:text="生产环境"
android:textColor="#ff141428"
android:textSize="28sp" />
</RadioGroup>
<!-- 分割线 -->
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="24dp"
android:background="#FFDCDCF0" />
<!-- 底部按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="88dp"
android:layout_marginTop="24dp"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnCancel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="20dp"
android:layout_weight="1"
android:background="@drawable/bg_cancel"
android:text="取消"
android:textColor="@color/bg_card_blue"
android:textSize="28sp" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnConfirm"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="20dp"
android:layout_weight="1"
android:background="@drawable/bg_confirm"
android:text="确认"
android:textColor="#FFFFFF"
android:textSize="28sp" />
</LinearLayout>
</LinearLayout>
+2 -2
View File
@@ -3,8 +3,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layoutCard"
android:layout_width="match_parent"
android:layout_height="216dp"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="12dp"
app:cardBackgroundColor="@color/bg_card_blue"
app:cardCornerRadius="12dp"
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="120dp"
android:layout_height="wrap_content"
android:background="@android:color/white"
android:elevation="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/tv_env_switch"
android:layout_width="match_parent"
android:layout_height="50dp"
android:gravity="center"
android:text="切换环境"
android:textColor="#333333"
android:textSize="14sp" />
<View
android:layout_width="match_parent"
android:layout_height="0.5dp"
android:background="#E0E0E0" />
<TextView
android:id="@+id/tv_log"
android:layout_width="match_parent"
android:layout_height="50dp"
android:gravity="center"
android:text="查看日志"
android:textColor="#333333"
android:textSize="14sp" />
</LinearLayout>
@@ -0,0 +1,475 @@
package com.shuwei.intelligent.shelves.serial
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
/**
* ScaleFrameParser 单元测试
*
* 测试数据来源:秤心跳数据.md 中的实际抓包数据
* 关键验证点:重量使用 BCD 解码(而非十六进制),与旧代码存在差异
*/
class ScaleFrameParserTest {
private lateinit var parser: ScaleFrameParser
// ----------------------------------------------------------------
// 测试数据常量(来自 秤心跳数据.md)
// ----------------------------------------------------------------
/** 旧设备(12台秤)心跳帧1 */
private val OLD_HEARTBEAT_1 =
"D6DADEDB01010C0000000008000000020000800100008000000000010000800500008001000080030000800200008099180080721800800000000000000000000000000000000000000000010101015037414645333633333334333731373056322E313007FFFF"
/** 旧设备(12台秤)心跳帧2 */
private val OLD_HEARTBEAT_2 =
"D6DADEDB01010C0900000027170000122500007511000001000000090000001100000012000000430000000800008000150080591500800000000000000000000000000000000000000000010100005031323136334238423134303233303456322E313112FFFF"
/** 新设备(24台秤)心跳帧1 */
private val NEW_HEARTBEAT_1 =
"D6DADEDB0101189322000000000000053300001534000079230000611100000000000007000080030000000300000004000080010000000100008003000000180600004214008083130080741200800115008066130080501400800411008033120080631600800000000000000000000000000000000000000000010101015031463137334238423134303233303456322E313130FFFF"
/** 旧设备(12台秤)获取状态响应 */
private val OLD_STATUS =
"D6DADEDB0302010100000C1100000027170000122500007711000001000000110000001300000012000000430000001000008000150080441500805031323136334238423134303233303456322E313113FFFF"
/** 新设备(24台秤)获取状态响应 */
private val NEW_STATUS =
"D6DADEDB030201010101181422000037110000783100009033000054000080100000000100000028240000070000000100008006000080030000801806000009000000461100004014008081130080721200800115008064130080501400809810008033120080651600805031463137334238423134303233303456322E31314AFFFF"
@Before
fun setUp() {
parser = ScaleFrameParser()
}
// ================================================================
// 一、重量 BCD 解析测试
// ================================================================
/**
* 协议文档示例:13 10 00 00 = 1.013KG = 1013g
* 旧代码(hex):0x1013 = 4115g ← 错误
* 新代码(BCD):"00001013".toLong() = 1013g ← 正确
*/
@Test
fun testWeight_protocolExample_1013g() {
val bytes = byteArrayOf(0x13, 0x10, 0x00, 0x00)
val result = parser.parseWeightBytes(bytes, 0)
assertEquals("协议示例 1.013KG 解析错误", 1013, result)
}
/**
* 协议文档示例:13 01 02 00 = 20.113KG = 20113g
* 旧代码(hex):0x20113 = 131347... 越界 ← 错误
* 新代码(BCD):"00020113".toLong() = 20113g ← 正确
*/
@Test
fun testWeight_protocolExample_20113g() {
val bytes = byteArrayOf(0x13, 0x01, 0x02, 0x00)
val result = parser.parseWeightBytes(bytes, 0)
assertEquals("协议示例 20.113KG 解析错误", 20113, result)
}
/**
* 协议文档示例:13 10 00 80 = -1.013KG = -1013g(负数,最高位为1
*/
@Test
fun testWeight_protocolExample_negative_1013g() {
val bytes = byteArrayOf(0x13, 0x10, 0x00, 0x80.toByte())
val result = parser.parseWeightBytes(bytes, 0)
assertEquals("协议示例 -1.013KG 解析错误", -1013, result)
}
/** 零重量 */
@Test
fun testWeight_zero() {
val bytes = byteArrayOf(0x00, 0x00, 0x00, 0x00)
assertEquals(0, parser.parseWeightBytes(bytes, 0))
}
/**
* 实际心跳数据:08 00 00 00 = 8g(正数,小值时 BCD 与 hex 相同)
*/
@Test
fun testWeight_actual_8g() {
val bytes = byteArrayOf(0x08, 0x00, 0x00, 0x00)
assertEquals(8, parser.parseWeightBytes(bytes, 0))
}
/**
* 实际心跳数据:02 00 00 80 = -2g(负数小值)
*/
@Test
fun testWeight_actual_negative_2g() {
val bytes = byteArrayOf(0x02, 0x00, 0x00, 0x80.toByte())
assertEquals(-2, parser.parseWeightBytes(bytes, 0))
}
/**
* 实际心跳数据:99 18 00 80 = -1899g
* 旧代码(hex):-0x1899 = -6297g ← 错误(差3倍多)
* 新代码(BCD):-1899g ← 正确(约1.9KG货物)
*/
@Test
fun testWeight_actual_negative_1899g() {
val bytes = byteArrayOf(0x99.toByte(), 0x18, 0x00, 0x80.toByte())
val result = parser.parseWeightBytes(bytes, 0)
assertEquals("心跳数据 -1899g 解析错误,旧代码会给出 -6297g", -1899, result)
}
/**
* 实际心跳数据:72 18 00 80 = -1872g
* 旧代码(hex):-0x1872 = -6258g ← 错误
* 新代码(BCD):-1872g ← 正确
*/
@Test
fun testWeight_actual_negative_1872g() {
val bytes = byteArrayOf(0x72, 0x18, 0x00, 0x80.toByte())
assertEquals(-1872, parser.parseWeightBytes(bytes, 0))
}
/**
* 实际状态数据:27 17 00 00 = 1727g
* 旧代码(hex):0x1727 = 5927g ← 错误(3.4倍)
* 新代码(BCD):1727g ← 正确(约1.7KG货物,合理)
*/
@Test
fun testWeight_actual_1727g() {
val bytes = byteArrayOf(0x27, 0x17, 0x00, 0x00)
val result = parser.parseWeightBytes(bytes, 0)
assertEquals("状态数据 1727g 解析错误,旧代码会给出 5927g", 1727, result)
}
/**
* 实际状态数据:12 25 00 00 = 2512g
* 旧代码(hex):0x2512 = 9490g ← 错误
* 新代码(BCD):2512g ← 正确
*/
@Test
fun testWeight_actual_2512g() {
val bytes = byteArrayOf(0x12, 0x25, 0x00, 0x00)
assertEquals(2512, parser.parseWeightBytes(bytes, 0))
}
/** 验证 offset 参数:从字节数组中间某位置解析 */
@Test
fun testWeight_withOffset() {
val bytes = byteArrayOf(
0x00, 0x00, // 前缀,不参与解析
0x13, 0x10, 0x00, 0x00 // 1013g,从 offset=2 解析
)
assertEquals(1013, parser.parseWeightBytes(bytes, 2))
}
// ================================================================
// 二、帧结构解析测试(parseFrame)
// ================================================================
@Test
fun testParseFrame_oldHeartbeat1_basicFields() {
val frame = parser.parseFrame(OLD_HEARTBEAT_1)
assertNotNull("帧解析不应返回 null", frame)
frame!!
assertEquals("CMD 应为 0x01(心跳)", 0x01.toByte(), frame.cmd)
assertEquals("FLAG 应为 0x01(终端发起)", 0x01.toByte(), frame.flag)
}
@Test
fun testParseFrame_oldHeartbeat1_fixedBody() {
val frame = parser.parseFrame(OLD_HEARTBEAT_1)!!
// 37414645333633333334333731373056322E313007FFFF 前固定数据体
// 终端ID: 37 41 46 45 33 36 33 33 33 34 33 37 31 37 30 → "7AFE36333437170"
assertEquals("终端ID长度应为15", 15, frame.terminalId.length)
assertEquals("终端ID解析错误", "7AFE36333437170", frame.terminalId)
// 固件版本: 56 32 2E 31 30 → "V2.10"
assertEquals("固件版本解析错误", "V2.10", frame.firmwareVersion)
assertEquals("命令次数解析错误", 7, frame.cmdCount)
}
@Test
fun testParseFrame_oldHeartbeat2_fixedBody() {
val frame = parser.parseFrame(OLD_HEARTBEAT_2)!!
// 终端ID: 50 31 32 31 36 33 42 38 42 31 34 30 32 33 30 → "P12163B8B140230"
assertEquals("终端ID解析错误", "12163B8B1402304", frame.terminalId)
assertEquals("固件版本解析错误", "V2.11", frame.firmwareVersion)
assertEquals("命令次数解析错误", 18, frame.cmdCount)
}
@Test
fun testParseFrame_newHeartbeat1_fixedBody() {
val frame = parser.parseFrame(NEW_HEARTBEAT_1)!!
// 终端ID: 50 31 46 31 37 33 42 38 42 31 34 30 32 33 30 → "P1F173B8B140230"
assertEquals("新设备终端ID解析错误", "1F173B8B1402304", frame.terminalId)
assertEquals("新设备固件版本解析错误", "V2.11", frame.firmwareVersion)
assertEquals("新设备命令次数解析错误", 48, frame.cmdCount)
}
@Test
fun testParseFrame_invalidData_returnsNull() {
assertNull("空字符串应返回 null", parser.parseFrame(""))
assertNull("过短数据应返回 null", parser.parseFrame("D6DADEDB0101FFFF"))
assertNull("无效协议头应返回 null", parser.parseFrame("AABBCCDD0101FFFF"))
}
// ================================================================
// 三、心跳数据解析测试(parseHeartbeat
// ================================================================
@Test
fun testHeartbeat_oldDevice_scaleCount() {
val frame = parser.parseFrame(OLD_HEARTBEAT_1)!!
val data = parser.parseHeartbeat(frame.rawBody)
assertNotNull("心跳数据不应为 null", data)
assertEquals("旧设备秤数量应为12", 12, data!!.scaleCount)
}
@Test
fun testHeartbeat_oldDevice_weights() {
val frame = parser.parseFrame(OLD_HEARTBEAT_1)!!
val data = parser.parseHeartbeat(frame.rawBody)!!
val weights = data.weights
assertEquals("秤列表大小应与秤数量一致", 12, weights.size)
// 秤100000000 → 0g
assertEquals("秤1重量", 0, weights[0].grams)
assertEquals("秤1编号", 1, weights[0].index)
// 秤208000000 → 8g
assertEquals("秤2重量", 8, weights[1].grams)
// 秤302000080 → -2g(负数)
assertEquals("秤3重量(去皮负值)", -2, weights[2].grams)
// 秤401000080 → -1g
assertEquals("秤4重量", -1, weights[3].grams)
// 秤500000000 → 0g
assertEquals("秤5重量", 0, weights[4].grams)
// 秤705000080 → -5g
assertEquals("秤7重量", -5, weights[6].grams)
// 秤1199180080 → -1899gBCD;旧代码 hex 会得到 -6297g
assertEquals("秤11重量(BCD:-1899ghex错误值:-6297g", -1899, weights[10].grams)
// 秤1272180080 → -1872gBCD;旧代码 hex 会得到 -6258g
assertEquals("秤12重量(BCD:-1872ghex错误值:-6258g", -1872, weights[11].grams)
}
@Test
fun testHeartbeat_oldDevice_lockStates() {
val frame = parser.parseFrame(OLD_HEARTBEAT_1)!!
val data = parser.parseHeartbeat(frame.rawBody)!!
assertEquals("锁1门状态应为开(1)", 1, data.lock1Door)
assertEquals("锁1锁状态应为开(1)", 1, data.lock1Lock)
assertEquals("锁2门状态应为开(1)", 1, data.lock2Door)
assertEquals("锁2锁状态应为开(1)", 1, data.lock2Lock)
}
@Test
fun testHeartbeat_oldDevice_temperature() {
val frame = parser.parseFrame(OLD_HEARTBEAT_1)!!
val data = parser.parseHeartbeat(frame.rawBody)!!
// 温度字节 0x50 = 80
assertEquals("温度字节应为 800x50", 80, data.temperature)
}
@Test
fun testHeartbeat_newDevice_scaleCount() {
val frame = parser.parseFrame(NEW_HEARTBEAT_1)!!
val data = parser.parseHeartbeat(frame.rawBody)!!
assertEquals("新设备秤数量应为24", 24, data.scaleCount)
assertEquals("秤列表大小应为24", 24, data.weights.size)
}
@Test
fun testHeartbeat_newDevice_weight1() {
val frame = parser.parseFrame(NEW_HEARTBEAT_1)!!
val data = parser.parseHeartbeat(frame.rawBody)!!
// 秤193220000 → big-endian "00002293" → BCD 2293g
assertEquals("新设备秤1重量(BCD:2293ghex错误值:8851g)", 2293, data.weights[0].grams)
}
@Test
fun testHeartbeat_newDevice_weight3() {
val frame = parser.parseFrame(NEW_HEARTBEAT_1)!!
val data = parser.parseHeartbeat(frame.rawBody)!!
// 秤305330000 → big-endian "00003305" → BCD 3305g
assertEquals("新设备秤3重量(BCD:3305ghex错误值:13061g)", 3305, data.weights[2].grams)
}
@Test
fun testHeartbeat_weightKg() {
val weight = ScaleWeight(index = 1, grams = 1013)
assertEquals("kg 转换错误", 1.013, weight.kg, 0.0001)
val negWeight = ScaleWeight(index = 2, grams = -1899)
assertEquals("负重量 kg 转换错误", -1.899, negWeight.kg, 0.0001)
}
// ================================================================
// 四、获取状态数据解析测试(parseStatus
// ================================================================
@Test
fun testStatus_oldDevice_lockStates() {
val frame = parser.parseFrame(OLD_STATUS)!!
assertEquals("CMD 应为 0x03(获取状态)", 0x03.toByte(), frame.cmd)
val data = parser.parseStatus(frame.rawBody)!!
assertEquals("锁1锁状态", 1, data.lock1Lock)
assertEquals("锁1门状态", 1, data.lock1Door)
assertEquals("锁2锁状态", 0, data.lock2Lock)
assertEquals("锁2门状态", 0, data.lock2Door)
}
@Test
fun testStatus_oldDevice_scaleCount() {
val frame = parser.parseFrame(OLD_STATUS)!!
val data = parser.parseStatus(frame.rawBody)!!
assertEquals("旧设备状态秤数量应为12", 12, data.scaleCount)
}
@Test
fun testStatus_oldDevice_weights() {
val frame = parser.parseFrame(OLD_STATUS)!!
val data = parser.parseStatus(frame.rawBody)!!
val weights = data.weights
// 秤111000000 → big-endian "00000011" → BCD 11ghex也是17g,差异小但仍不同)
assertEquals("状态秤1重量(BCD:11ghex:17g)", 11, weights[0].grams)
// 秤227170000 → big-endian "00001727" → BCD 1727ghex:5927g,差异巨大)
assertEquals("状态秤2重量(BCD:1727ghex错误值:5927g)", 1727, weights[1].grams)
// 秤312250000 → BCD 2512ghex:9490g
assertEquals("状态秤3重量(BCD:2512ghex错误值:9490g)", 2512, weights[2].grams)
// 秤477110000 → BCD 1177ghex:4471g
assertEquals("状态秤4重量(BCD:1177ghex错误值:4471g)", 1177, weights[3].grams)
// 秤501000000 → 1g
assertEquals("状态秤5重量", 1, weights[4].grams)
// 秤1010000080 → -10g(负数)
assertEquals("状态秤10重量(去皮负值)", -10, weights[9].grams)
// 秤1100150080 → big-endian "80001500",清符号位 "00001500" → BCD -1500g
assertEquals("状态秤11重量(BCD:-1500ghex:-5376g)", -1500, weights[10].grams)
// 秤1244150080 → "00001544" → BCD -1544g
assertEquals("状态秤12重量(BCD:-1544ghex:-5444g)", -1544, weights[11].grams)
}
@Test
fun testStatus_newDevice_scaleCount() {
val frame = parser.parseFrame(NEW_STATUS)!!
val data = parser.parseStatus(frame.rawBody)!!
assertEquals("新设备状态秤数量应为24", 24, data.scaleCount)
}
@Test
fun testStatus_newDevice_weight1() {
val frame = parser.parseFrame(NEW_STATUS)!!
val data = parser.parseStatus(frame.rawBody)!!
// 秤114220000 → big-endian "00002214" → BCD 2214g
assertEquals("新设备状态秤1重量(BCD:2214g)", 2214, data.weights[0].grams)
}
// ================================================================
// 五、分片拼装测试(appendData
// ================================================================
@Test
fun testAppendData_completeFrame_returnedImmediately() {
val result = parser.appendData(OLD_HEARTBEAT_1)
assertNotNull("完整帧应直接返回", result)
assertEquals("返回的帧内容应完整", OLD_HEARTBEAT_1, result)
}
@Test
fun testAppendData_twoFragments() {
// 将完整帧从中间切分为两段
val mid = OLD_HEARTBEAT_1.length / 2
val part1 = OLD_HEARTBEAT_1.substring(0, mid)
val part2 = OLD_HEARTBEAT_1.substring(mid)
// 第一段(含帧头,不含帧尾)→ 应返回 null
val result1 = parser.appendData(part1)
assertNull("第一分片不应返回完整帧", result1)
// 第二段(含帧尾,不含帧头)→ 应拼装完成并返回完整帧
val result2 = parser.appendData(part2)
assertNotNull("第二分片拼装后应返回完整帧", result2)
assertEquals("拼装后帧内容应与原始数据一致", OLD_HEARTBEAT_1, result2)
}
@Test
fun testAppendData_threeFragments() {
// 将完整帧切成三段
val len = OLD_HEARTBEAT_1.length
val part1 = OLD_HEARTBEAT_1.substring(0, len / 3)
val part2 = OLD_HEARTBEAT_1.substring(len / 3, len * 2 / 3)
val part3 = OLD_HEARTBEAT_1.substring(len * 2 / 3)
assertNull("第1段不应返回完整帧", parser.appendData(part1))
assertNull("第2段不应返回完整帧", parser.appendData(part2))
val result = parser.appendData(part3)
assertNotNull("第3段拼装后应返回完整帧", result)
assertEquals("三段拼装结果应与原始数据一致", OLD_HEARTBEAT_1, result)
}
@Test
fun testAppendData_reset_clearsBuffer() {
val mid = OLD_HEARTBEAT_1.length / 2
// 先追加第一段(写入缓冲)
parser.appendData(OLD_HEARTBEAT_1.substring(0, mid))
// 重置后再追加完整帧,应正常返回
parser.reset()
val result = parser.appendData(OLD_HEARTBEAT_1)
assertNotNull("reset 后追加完整帧应正常返回", result)
}
@Test
fun testAppendData_newFrameAfterComplete() {
// 模拟连续两帧数据,第二帧紧跟在第一帧末尾
val twoFrames = OLD_HEARTBEAT_1 + OLD_HEARTBEAT_2
// 包含帧头和帧尾,应只返回第一帧
val result = parser.appendData(twoFrames)
assertNotNull("连续两帧中应能解析出完整帧", result)
assertTrue("应返回以协议头开头的帧", result!!.startsWith("D6DADEDB"))
assertTrue("应返回以协议尾结尾的帧", result.endsWith("FFFF"))
}
// ================================================================
// 六、端到端集成测试(appendData + parseFrame + parseHeartbeat
// ================================================================
@Test
fun testEndToEnd_oldHeartbeat_fragmentedThenParse() {
val mid = OLD_HEARTBEAT_1.length / 2
parser.appendData(OLD_HEARTBEAT_1.substring(0, mid))
val frameHex = parser.appendData(OLD_HEARTBEAT_1.substring(mid))!!
val frame = parser.parseFrame(frameHex)!!
val data = parser.parseHeartbeat(frame.rawBody)!!
assertEquals(0x01.toByte(), frame.cmd)
assertEquals(12, data.scaleCount)
assertEquals(8, data.weights[1].grams) // 秤2: 8g
assertEquals(-1899, data.weights[10].grams) // 秤11: -1899g
assertEquals("7AFE36333437170", frame.terminalId)
}
@Test
fun testEndToEnd_newHeartbeat_parseComplete() {
val frameHex = parser.appendData(NEW_HEARTBEAT_1)!!
val frame = parser.parseFrame(frameHex)!!
val data = parser.parseHeartbeat(frame.rawBody)!!
assertEquals(24, data.scaleCount)
assertEquals(2293, data.weights[0].grams) // 秤1: 2293g
assertEquals("1F173B8B1402304", frame.terminalId)
assertEquals("V2.11", frame.firmwareVersion)
}
}
+74
View File
@@ -0,0 +1,74 @@
协议头 4 字节
命令 1 字节
标志 1 字节
数据体 :不同命令不固定,详细见下方
协议尾 2字节
协议头 0xD6 0xDA 0xDE 0xDB
命令:
◼ 0x00:终端激活(终端发起)
◼ 0x01:心跳(终端发起)
◼ 0x81:心跳(终端发起,当终端发送此命令时需要服务端存储串口屏显示信息)
◼ 0x02:开锁(服务端发起)
◼ 0x03:获取状态(服务端发起)
◼ 0x04:秤置零(服务端发起)
◼ 0x06:秤标定(服务端发起)
◼ 0x05:卡号上发(终端发起)
◼ 0x07:价签设置(服务端发起)
◼ 0x08:串口屏(服务端发起)
◼ 0x09:语音播报(服务端发起)
◼ 0x0A:价签(第二块串口屏/透传)设置(服务端发起)
◼ 0x0B:操作记录上传(终端发起)
◼ 0x0C:对时(终端发起)
◼ 0x0E:终端位置上报(终端发起)
◼ 0x0F:温控器控制(服务端发起)
◼ 0x10:温控信息上报(终端发起)
◼ 0x11:临时参数设置(服务端发起)
固定数据体 :(终端ID :15 字节,固件版本 :5字节,命令次数 :1字节)
0x01心跳终端发起数据体内容格式:
秤数量 1 字节
N个秤重量数据 :N x 4字节
物联卡号 20 字节
锁1门状态 1 字节
锁1锁状态 1 字节
锁2门状态 1 字节
锁2锁状态 1 字节
温度 1 字节
固定数据体 :(终端ID :15 字节,固件版本 :5字节,命令次数 :1字节)
0x03获取状态服务端响应数据体内容格式:
锁1锁状态 1 字节
锁1门状态 1 字节
锁2锁状态 1 字节
锁2门状态 1 字节
秤数量 1 字节
N个秤重量数据 :N x 4字节
温度 1 字节
固定数据体 :(终端ID :15 字节,固件版本 :5字节,命令次数 :1字节)
示例数据见下方----------------------------
旧设备10台秤心跳数据:
D6DADEDB01010C0000000008000000020000800100008000000000010000800500008001000080030000800200008099180080721800800000000000000000000000000000000000000000010101015037414645333633333334333731373056322E313007FFFF
D6DADEDB01010C0900000027170000122500007511000001000000090000001100000012000000430000000800008000150080591500800000000000000000000000000000000000000000010100005031323136334238423134303233303456322E313112FFFF
新设备15台秤心跳数据:
D6DADEDB0101189322000000000000053300001534000079230000611100000000000007000080030000000300000004000080010000000100008003000000180600004214008083130080741200800115008066130080501400800411008033120080631600800000000000000000000000000000000000000000010101015031463137334238423134303233303456322E313130FFFF
D6DADEDB0101181422000037110000823100009233000054000080100000000100000030240000050000000300008002000080050000802006000007000000441100004014008081130080721200800315008064130080521400809610008033120080631600800000000000000000000000000000000000000000010101015031463137334238423134303233303456322E313147FFFF
D6DADEDB0101180635000075190000842800000100008014000080060000800100008004000080050000801500008007000000080000800700008003000000050000008118008054180080231800801018008059170080221800809418008075320080761900800000000000000000000000000000000000000000010101015033453137334238423134303233303456322E313139FFFF
旧设备10台秤获取状态数据:
D6DADEDB0302010100000C1100000027170000122500007711000001000000110000001300000012000000430000001000008000150080441500805031323136334238423134303233303456322E313113FFFF
新设备15台秤获取状态数据:
D6DADEDB030201010101180635000077190000842800000100008014000080040000800100008004000080050000801500008007000000080000800900008005000000030000008118008054180080231800801018008059170080221800809418008079320080761900805033453137334238423134303233303456322E31313CFFFF
D6DADEDB030201010101181422000037110000783100009033000054000080100000000100000028240000070000000100008006000080030000801806000009000000461100004014008081130080721200800115008064130080501400809810008033120080651600805031463137334238423134303233303456322E31314AFFFF
Binary file not shown.