Compare commits

...

145 Commits

Author SHA1 Message Date
c55f128c22 登录修改 2026-04-15 08:48:26 +08:00
4137bc10d7 修改足底压力样式 2026-02-09 09:44:16 +08:00
6e4ec82e17 合并代码 2026-02-09 09:34:20 +08:00
42570d633a 修改足底压力样式 2026-02-09 09:32:23 +08:00
1321f557d2 提交变更 2026-02-09 08:54:51 +08:00
5ed553b2a0 减少登录等待时间。 2026-02-07 14:24:08 +08:00
75c3380ff3 进入检测页面,增加了设备是否就绪的检测。 2026-02-07 13:41:31 +08:00
def6304bd9 修正了检测截图排序问题,增加了足底压力板有效性设置功能。 2026-02-07 11:46:13 +08:00
e3e294694c 修改足底视频bug 2026-02-06 18:45:08 +08:00
68bbeb63fd 修改检查页面样式 2026-02-06 18:36:27 +08:00
c71f07f931 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2026-02-04 16:34:37 +08:00
6499926298 修改查看视频 2026-02-04 16:34:25 +08:00
cd35871476 修改了后台视频录制等功能 2026-02-04 16:05:48 +08:00
5059ad6158 修复了测试提交bug。 排序、蓝牙名称修改不生效等 2026-01-27 18:16:12 +08:00
641bc9e8a0 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2026-01-22 18:18:46 +08:00
e95c771171 按照需求修改(首页布局,足底,视频等) 2026-01-22 18:18:29 +08:00
d5d66a91a2 增了电池电量显示 2026-01-19 08:57:05 +08:00
41f9b283c2 recover: 恢复 5:30 前误回退的代码 2026-01-15 18:05:35 +08:00
599d1f6807 按照效果图修改样式 2026-01-15 16:05:19 +08:00
653f4e1666 按照效果图修改样式 2026-01-15 15:17:16 +08:00
61a43a2800 修改生成报告身体姿态高度 2026-01-14 11:31:05 +08:00
6ff9e0c8ca Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2026-01-14 11:19:27 +08:00
79e0f6de87 修改报告样式 2026-01-14 11:19:17 +08:00
a368d24156 增加了属性 2026-01-13 18:21:58 +08:00
6dbde36852 修改了imu连接、重启相关功能 2026-01-13 18:15:51 +08:00
0f0ca132d3 相机参数设置样式修改 2026-01-13 09:26:00 +08:00
8ff04936da 足部压力修改 2026-01-13 09:18:27 +08:00
1ea823fe71 修改头部姿态样式 2026-01-13 09:06:28 +08:00
8a73144305 首页详细信息列表修改 2026-01-13 08:54:31 +08:00
e0131da087 修改页头文字已激活改为已授权 2026-01-13 08:48:37 +08:00
e39876a2ac 修改检测记录列表删除图片 2026-01-13 08:43:54 +08:00
c3ced25fe1 修改报告中的头部姿态样式 2026-01-12 17:09:58 +08:00
6ec453a63d 增加imu设备相关代码 2026-01-12 15:21:44 +08:00
c1cb2a0970 修改诊断数据处理和建议新增按钮样式 2026-01-12 15:03:38 +08:00
e8ca34c34b 添加检查边框颜色,添加有无遥控器判断 2026-01-12 11:13:31 +08:00
df63b22e7a 修改样式,根据需求修改 2026-01-12 11:03:06 +08:00
5ed15ce017 修正了main设备初始化顺序、pdf黑白bug、桌面快捷图标bug 2026-01-11 21:50:12 +08:00
685765e2b2 清理了屏幕录制,截图相关方法。调整了系统启动逻辑。 2026-01-11 11:38:02 +08:00
9f2ecf02c4 修改了配置及相关bug 2026-01-10 22:28:56 +08:00
93e45aec19 修改相册样式和接口联调 2026-01-09 19:58:23 +08:00
513e9867a6 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2026-01-09 18:55:02 +08:00
2accaa48a6 截图传足部压力尺寸,图片相册添加假数据和样式 2026-01-09 18:54:59 +08:00
c91f1d7481 获取数据 2026-01-09 18:36:47 +08:00
a08f666306 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2026-01-09 18:13:14 +08:00
33d5e5a6e5 修改按钮样式,视频判断有几个,诊断信息处理删除bug,视频显示修改 2026-01-09 18:13:10 +08:00
7eff717256 修正了检查session的statsu 2026-01-09 15:53:46 +08:00
36c399c009 提交代码 2026-01-09 14:51:11 +08:00
33a5d8b3a3 增加了各类设备是否enabled的设置 2026-01-09 14:50:25 +08:00
394e79d770 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2026-01-09 09:44:37 +08:00
d85b6feff9 样式版本更新 2026-01-09 09:44:32 +08:00
99e35eba95 增加了遥控器控制功能 2026-01-09 09:40:00 +08:00
1ae2146ff0 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-12-15 12:57:29 +08:00
d2046fdc2e logo更新 2025-12-15 12:57:19 +08:00
b3ae7ec7b8 修改报告图片样式 2025-12-15 12:18:06 +08:00
a8654c4036 修改报告样式,刷新首页列表 2025-12-15 10:57:07 +08:00
8e6a7518d2 修改了electron 配置路径 2025-12-13 14:27:36 +08:00
root
c65a89ed6f 增加了系统启动等待效果 2025-12-13 14:10:45 +08:00
root
23f0fc9bba Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-12-13 12:54:49 +08:00
root
4f27b86e76 chore: apply .gitignore and clean git cache 2025-12-13 12:52:01 +08:00
1cfba3827c 提交 2025-12-13 12:06:12 +08:00
f3e2df3c02 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-12-12 19:02:16 +08:00
69aaaeac09 修改了背景图 2025-12-12 19:02:04 +08:00
9c616fc467 修改处理样式 2025-12-12 18:58:29 +08:00
b014088d22 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-12-12 18:18:29 +08:00
c2cfb192d5 修改处理样式 2025-12-12 18:18:25 +08:00
b273732450 提交区域 2025-12-12 18:09:09 +08:00
5de70f5dbb Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-12-12 18:02:58 +08:00
ed0ae9f3ce 修改了common_items_path 配置文件路径 2025-12-12 18:02:46 +08:00
ff7f1b09dd 合并冲突 2025-12-12 17:36:02 +08:00
6a45190e0b 修改样式 2025-12-12 17:34:30 +08:00
01062249e3 增加了快捷输入的问题。 2025-12-12 17:33:14 +08:00
8928732f0a Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-12-12 14:27:56 +08:00
ac9c10ce49 添加了ffmpeg设置 2025-12-12 14:27:46 +08:00
4ab00a7702 合并代码 2025-12-12 14:26:27 +08:00
978ae089d1 追加 2025-12-12 13:36:17 +08:00
4a01758e8b 追加 2025-12-12 13:35:39 +08:00
20ef0cef86 优化打印样式 2025-12-12 13:33:34 +08:00
5692d87815 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-12-12 13:16:17 +08:00
fbe368ee20 更新了屏幕录制方法。 2025-12-12 13:12:39 +08:00
1a842b93e0 添加单报告对比模块 2025-12-11 16:57:37 +08:00
da041bd292 合并冲突 2025-12-11 16:19:34 +08:00
61b720fff4 提交双报告对比模块 2025-12-11 16:18:59 +08:00
b74d9129fe 修改了首页患者列表信息 2025-12-11 15:23:24 +08:00
5ec2adc833 合并代码 2025-12-11 13:33:45 +08:00
74a20ea351 合并代码 2025-12-11 13:32:28 +08:00
c97cbbf7e1 Merge remote-tracking branch 'origin/dev-v15' into dev-v15 2025-12-11 13:16:51 +08:00
aeb6e9ab3f Merge branch 'dev-v15' into dev-v15 2025-12-11 13:08:43 +08:00
d5eeb77f4f 修改删除图标 2025-12-11 12:06:27 +08:00
5fb58a6f7b 修改了代码 2025-12-11 12:04:31 +08:00
d9b6141d54 合并冲突 2025-12-11 09:51:58 +08:00
3a46cac625 添加报告对比模块 2025-12-11 09:51:36 +08:00
eec871fc05 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-12-11 09:49:13 +08:00
1ed382f126 提交 2025-12-11 09:49:02 +08:00
20ef65112d 合并代码 2025-12-11 08:23:42 +08:00
d4d4b5bee5 追加 2025-12-10 18:31:00 +08:00
268c9109d0 修改样式 2025-12-10 18:28:44 +08:00
79b8268ff8 检测页面性能测试 2025-12-10 15:35:50 +08:00
0b1af5bc78 预览pdf(未完成) 2025-12-09 18:18:22 +08:00
68936a6ab3 修改order为id 2025-12-09 15:18:45 +08:00
af1e5f6d18 提交 2025-12-09 14:07:48 +08:00
c00fdf19b6 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-12-09 13:37:39 +08:00
d5468ccc5b 增加了删除报告的方法。 2025-12-09 13:37:28 +08:00
4173c3b862 合并冲突 2025-12-09 13:35:12 +08:00
3e3a0aef34 追加提交 2025-12-09 13:33:28 +08:00
b114ee6e0a 追加文档 2025-12-09 13:31:01 +08:00
e8673b7115 优化提交 2025-12-09 13:27:39 +08:00
bec4b9a392 Resolve merge conflicts and persist PDF generation fixes 2025-12-09 13:13:32 +08:00
ff46066fab 添加生成报告相关方法 2025-12-09 13:00:15 +08:00
2772a2a9f2 Fix PDF generation and report upload: Fix pagination, footer font, backend upload logic, and UI handlers 2025-12-09 12:59:04 +08:00
1e77e93d79 追加 2025-12-09 11:25:51 +08:00
120d4a0e2f 新增单独报告 2025-12-09 09:48:42 +08:00
38851f4844 删除多余图片 2025-12-08 17:11:56 +08:00
bf359d82cd 添加导出fpd报告功能 2025-12-08 15:21:37 +08:00
ed72ce3cd1 修改登录注册忘记密码样式 2025-12-08 14:04:03 +08:00
a30f5e3ac2 修改报告样式和查看详情样式 2025-12-08 13:39:41 +08:00
4ee9eb2eef Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-12-08 09:18:11 +08:00
58a135fe74 修改登录页面 2025-12-08 09:17:57 +08:00
d25ce01bb9 修改了模型文件命名,中文打包后有问题。 2025-12-08 09:17:00 +08:00
1b1bada016 提交了界面修改 2025-12-07 20:58:14 +08:00
ef88e599dd 测试提交 2025-12-07 20:15:07 +08:00
a7f48305be 修改了诊断页面和档案页面相关功能 2025-12-07 20:07:22 +08:00
d058f15488 修改患者档案 2025-12-05 18:09:55 +08:00
a04b2b5e36 修改报告详情 2025-12-05 15:23:19 +08:00
6ec26bfeaa 修正数据库错误 2025-12-04 12:11:56 +08:00
3b6280b7c5 修改档案 2025-12-04 12:01:24 +08:00
95cdd967cc 修改检查详情页 2025-12-03 17:05:13 +08:00
7ff97bd871 修改诊断评价和样式 2025-12-03 10:22:30 +08:00
c16dc20540 修改颜色 2025-12-03 08:49:53 +08:00
8dba853cf6 修改关闭新建患者页面bug 2025-12-03 08:45:15 +08:00
522685d8c1 修改3d出现双人头bug 2025-12-02 18:10:06 +08:00
69ec3e8d62 修改激活页面 2025-12-02 17:53:45 +08:00
73191de5b8 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-12-02 15:56:47 +08:00
c4cccfd3c2 修改档案页和新增修改页面样式 2025-12-02 15:56:36 +08:00
2b7c5b90e9 修正了录像的bug 2025-12-02 10:34:12 +08:00
c91a71a7da 删除人像 2025-12-02 09:06:27 +08:00
22e6a3f48a 修改了代码和bug 2025-12-02 08:53:04 +08:00
900ca4dd2c 添加三维模型(未完成) 2025-11-28 16:14:15 +08:00
8f1eb56a47 优化提交 2025-11-28 14:51:03 +08:00
94d52e4237 修改患者档案 2025-11-28 14:11:36 +08:00
da2dc38a51 Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15 2025-11-28 08:10:35 +08:00
79c6b5859b 修改患者档案页面 2025-11-28 08:10:31 +08:00
77d35fc4cc 修正了检测页面bug 2025-11-28 08:05:50 +08:00
fbe332a8a6 提交检测页面 2025-11-27 16:02:30 +08:00
fe79fe828a 提交样式未完成版本 2025-11-26 16:43:42 +08:00
96ba7c098a 增加了两个相机的支持。 2025-11-16 11:43:41 +08:00
200 changed files with 18101 additions and 121524 deletions

23927
.gitignore vendored

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +0,0 @@
{
"product": "BodyBalanceEvaluation",
"version": "1.0.0",
"machine_id": "W10-D13710C7BD317C29",
"platform": "Windows",
"request_time": "2025-11-04T05:35:19.472181+00:00",
"hardware_info": {
"system": "Windows",
"machine": "AMD64",
"processor": "Intel64 Family 6 Model 165 Stepping 2, GenuineIntel",
"node": "MSI"
},
"company_name": "北京天宏博科技有限公司",
"contact_info": "thb@163.com"
}

View File

@ -1,118 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['main.py'],
pathex=['D:/Trae_space/pyKinectAzure'],
binaries=[
# FemtoBolt相关库文件
('dll/femtobolt/k4a.dll', 'dll/femtobolt'), # K4A动态库
('dll/femtobolt/k4arecord.dll', 'dll/femtobolt'), # K4A录制库
('dll/femtobolt/depthengine_2_0.dll', 'dll/femtobolt'), # 深度引擎
('dll/femtobolt/OrbbecSDK.dll', 'dll/femtobolt'), # Orbbec SDK
('dll/femtobolt/k4a.lib', 'dll/femtobolt'), # K4A静态库
('dll/femtobolt/k4arecord.lib', 'dll/femtobolt'), # K4A录制静态库
('dll/femtobolt/k4arecorder.exe', 'dll/femtobolt'), # K4A录制工具
('dll/femtobolt/k4aviewer.exe', 'dll/femtobolt'), # K4A查看器
# SMiTSense相关库文件
('dll/smitsense/SMiTSenseUsb-F3.0.dll', 'dll/smitsense'), # SMiTSense传感器库
('dll/smitsense/Wrapper.dll', 'dll/smitsense'), # SMiTSense传感器库包装类
],
hiddenimports=[
'flask',
'flask_socketio',
'flask_cors',
'cv2',
'numpy',
'pandas',
'scipy',
'matplotlib',
'seaborn',
'sklearn',
'PIL',
'reportlab',
'sqlite3',
'configparser',
'logging',
'threading',
'queue',
'base64',
'psutil',
'pykinect_azure',
'pykinect_azure.k4a',
'pykinect_azure.k4abt',
'pykinect_azure.k4arecord',
'pykinect_azure.pykinect',
'pykinect_azure.utils',
'pykinect_azure._k4a',
'pykinect_azure._k4abt',
'pyserial',
'requests',
'yaml',
'click',
'colorama',
'tqdm',
'database',
'device_manager',
'utils',
'eventlet',
'socketio',
'engineio',
'engineio.async_drivers.threading',
'engineio.async_drivers.eventlet',
'engineio.async_eventlet',
'socketio.async_eventlet',
'greenlet',
'gevent',
'gevent.socket',
'gevent.select',
'dns',
'dns.resolver',
'dns.reversename',
'dns.e164',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='BodyBalanceBackend',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='BodyBalanceBackend'
)

View File

@ -233,7 +233,7 @@ def copy_data_files():
data_dir = os.path.join('dist', 'BodyBalanceBackend', 'data')
# 数据库文件列表 - 这些文件会在程序首次运行时自动创建
db_files = ['body_balance.db']
db_files = ['body_balance.db','common_items.json']
# 检查是否存在现有数据库文件,如果存在则复制
copied_count = 0
@ -276,6 +276,17 @@ def copy_config_files():
else:
print(f"⚠️ 配置文件不存在: {config_file}")
try:
ffmpeg_src = 'ffmpeg'
ffmpeg_dst = os.path.join(dist_dir, 'ffmpeg')
if os.path.exists(ffmpeg_src):
shutil.copytree(ffmpeg_src, ffmpeg_dst, dirs_exist_ok=True)
print(f"✓ 已复制 ffmpeg 目录到 {ffmpeg_dst}")
else:
print("⚠️ 未找到 ffmpeg 源目录ffmpeg")
except Exception as e:
print(f"⚠️ 复制 ffmpeg 目录失败: {e}")
def install_build_dependencies():
"""安装打包依赖"""
print("检查并安装打包依赖...")
@ -390,4 +401,4 @@ def main():
input("按回车键退出...")
if __name__ == '__main__':
main()
main()

View File

@ -1,6 +1,6 @@
[APP]
name = Body Balance Evaluation System
version = 1.0.0
version = 1.5.0
debug = True
log_level = INFO
@ -11,14 +11,15 @@ cors_origins = *
[DATABASE]
path = D:/BodyCheck/data/body_balance.db
common_items_path = D:/BodyCheck/data/common_items.json
backup_interval = 24
max_backups = 7
[FILEPATH]
path = D:/BodyCheck/file/
[CAMERA]
enabled = True
[CAMERA1]
enable = True
device_index = 0
width = 1280
height = 720
@ -27,8 +28,18 @@ buffer_size = 1
fourcc = MJPG
backend = directshow
[CAMERA2]
enable = True
device_index = 2
width = 1280
height = 720
fps = 30
buffer_size = 1
fourcc = MJPG
backend = directshow
[FEMTOBOLT]
enabled = True
enable = True
algorithm_type = plt
color_resolution = 1080P
depth_mode = NFOV_2X2BINNED
@ -39,17 +50,22 @@ fps = 15
synchronized_images_only = False
[DEVICES]
imu_enabled = True
imu_device_type = ble
imu_port = COM9
imu_mac_address = ef:3c:1a:0a:fe:02
imu_baudrate = 9600
pressure_enabled = True
pressure_device_type = real
pressure_use_mock = False
pressure_port = COM5
imu_enable = True
imu_use_mock = False
imu_ble_name = WT901BLE67
imu_mac_address = FA:E8:88:06:FE:F3
pressure_enable = True
pressure_use_mock = True
pressure_port = COM3
pressure_baudrate = 115200
[REMOTE]
enable = False
port = COM6
baudrate = 115200
timeout = 0.1
strict_crc = False
[SYSTEM]
log_level = INFO
max_cache_size = 10
@ -78,3 +94,13 @@ public_key = D:/BodyCheck/license/license_public_key.pem
grace_days = 7
dev_mode = False
[SCREEN_RECORDING]
strategy = ffmpeg
ffmpeg_path = D:/BodyCheck/ffmpeg/bin/ffmpeg.exe
ffmpeg_codec = libx264
ffmpeg_preset = ultrafast
ffmpeg_threads = 2
ffmpeg_bframes = 0
ffmpeg_gop = 50
ffmpeg_draw_mouse = 0

File diff suppressed because it is too large Load Diff

View File

@ -21,5 +21,5 @@ __all__ = [
'DeviceCoordinator'
]
__version__ = '1.0.0'
__version__ = '1.5.0'
__author__ = 'Body Balance Detection System'

View File

@ -33,6 +33,7 @@ class BaseDevice(ABC):
self.config = config
self.is_connected = False
self.is_streaming = False
self._initializing = False
self.socket_namespace = f"/{device_name}"
self.logger = logging.getLogger(f"device.{device_name}")
self._lock = threading.RLock() # 可重入锁
@ -246,10 +247,7 @@ class BaseDevice(ABC):
Dict[str, Any]: 设备信息
"""
with self._lock:
return self._device_info.copy()
return self._device_info.copy()
def _set_error(self, error_msg: str):
@ -311,7 +309,7 @@ class BaseDevice(ABC):
try:
# 检查硬件连接状态
hardware_connected = self.check_hardware_connection()
self.logger.info(f"检测到设备 {self.device_name} 硬件连接状态: {hardware_connected} is_connected{self.is_connected}")
# self.logger.info(f"检测到设备 {self.device_name} 硬件连接状态: {hardware_connected} is_connected{self.is_connected}")
# 如果硬件断开但软件状态仍为连接,则更新状态
if not hardware_connected and self.is_connected:
self.logger.warning(f"检测到设备 {self.device_name} 硬件连接断开")
@ -364,4 +362,4 @@ class BaseDevice(ABC):
self._stop_connection_monitor()
def __repr__(self):
return f"<{self.__class__.__name__}(name='{self.device_name}', connected={self.is_connected}, streaming={self.is_streaming})>"
return f"<{self.__class__.__name__}(name='{self.device_name}', connected={self.is_connected}, streaming={self.is_streaming})>"

View File

@ -14,6 +14,7 @@ from typing import Optional, Dict, Any
import logging
import queue
import gc
import os
try:
from .base_device import BaseDevice
@ -26,34 +27,49 @@ except ImportError:
class CameraManager(BaseDevice):
"""普通相机管理器"""
def __init__(self, socketio, config_manager: Optional[ConfigManager] = None):
def __init__(self, socketio, config_manager: Optional[ConfigManager] = None,
device_name: str = "camera1",
instance_config: Optional[Dict[str, Any]] = None):
"""
初始化相机管理器
Args:
socketio: SocketIO实例
config_manager: 配置管理器实例
device_name: 设备名称仅支持 'camera1' | 'camera2'
instance_config: 覆盖默认配置的实例级配置 device_index分辨率fps
"""
# 配置管理
self.config_manager = config_manager or ConfigManager()
config = self.config_manager.get_device_config('camera')
# 校验设备名,仅允许 camera1/camera2
if device_name not in ('camera1', 'camera2'):
raise ValueError(f"不支持的设备名: {device_name},仅支持 'camera1'/'camera2'")
# 根据设备名选择配置源:'camera1' 使用 [CAMERA1]'camera2' 使用 [CAMERA2]
base_key = 'camera1' if device_name == 'camera1' else 'camera2'
base_config = self.config_manager.get_device_config(base_key)
# 合并实例覆盖配置
if instance_config:
try:
base_config = {**base_config, **instance_config}
except Exception:
pass
super().__init__("camera", config)
super().__init__(device_name, base_config)
# 保存socketio实例
self._socketio = socketio
# 相机相关属性
self.cap = None
self.device_index = config.get('device_index', 0)
self.width = config.get('width', 1280)
self.height = config.get('height', 720)
self.fps = config.get('fps', 30)
self.buffer_size = config.get('buffer_size', 1)
self.fourcc = config.get('fourcc', 'MJPG')
self.device_index = base_config.get('device_index', 0)
self.width = base_config.get('width', 1280)
self.height = base_config.get('height', 720)
self.fps = base_config.get('fps', 30)
self.buffer_size = base_config.get('buffer_size', 1)
self.fourcc = base_config.get('fourcc', 'MJPG')
# OpenCV后端配置 (DirectShow性能最佳)
backend_name = config.get('backend', 'directshow').lower()
backend_name = base_config.get('backend', 'directshow').lower()
self.backend_map = {
'directshow': cv2.CAP_DSHOW,
'dshow': cv2.CAP_DSHOW,
@ -64,12 +80,12 @@ class CameraManager(BaseDevice):
self.backend_name = backend_name
# 额外可调的降采样宽度(不改变外部配置语义,仅内部优化传输)
self._tx_max_width = int(config.get('tx_max_width', 1920))
self._tx_max_width = int(base_config.get('tx_max_width', 1920))
# 流控制
self.streaming_thread = None
# 减小缓存长度保留最近2帧即可避免累计占用
self.frame_cache = queue.Queue(maxsize=int(config.get('frame_cache_len', 2)))
self.frame_cache = queue.Queue(maxsize=int(base_config.get('frame_cache_len', 2)))
self.last_frame = None
self.frame_count = 0
self.dropped_frames = 0
@ -80,13 +96,14 @@ class CameraManager(BaseDevice):
self.actual_fps = 0
# 重连与断连检测机制(-1 表示无限重连)
self.max_reconnect_attempts = int(config.get('max_reconnect_attempts', -1))
self.reconnect_delay = float(config.get('reconnect_delay', 2.0))
self.read_fail_threshold = int(config.get('read_fail_threshold', 30))
self.max_reconnect_attempts = int(base_config.get('max_reconnect_attempts', -1))
self.reconnect_delay = float(base_config.get('reconnect_delay', 2.0))
self.read_fail_threshold = int(base_config.get('read_fail_threshold', 30))
self._last_connected_state = None
# 设备标识和性能统计
self.device_id = f"camera_{self.device_index}"
# 使用设备名作为ID便于前端区分
self.device_id = device_name
self.performance_stats = {
'frames_processed': 0,
'actual_fps': 0,
@ -94,7 +111,19 @@ class CameraManager(BaseDevice):
}
# 全局帧队列(用于录制)
self.frame_queue = queue.Queue(maxsize=10) # 最大长度10自动丢弃旧帧
self.frame_queue = queue.Queue(maxsize=10)
self._recording_enabled = False
self._recording_session_id = None
self._recording_frames_dir = None
self._recording_target_fps = None
self._recording_last_ts = 0.0
self._recording_index = 0
self._recording_written = 0
self._recording_drop = 0
self._recording_queue = queue.Queue(maxsize=300)
self._recording_thread = None
self._recording_stop_event = threading.Event()
# 属性缓存机制 - 避免重复设置相同属性值
self._property_cache = {}
@ -109,6 +138,130 @@ class CameraManager(BaseDevice):
pass
self.logger.info(f"相机管理器初始化完成 - 设备索引: {self.device_index}")
def start_jpeg_recording(self, session_id: str, frames_dir: str, record_fps: Optional[int] = None) -> Dict[str, Any]:
try:
if not session_id:
return {'success': False, 'message': '缺少session_id'}
if not frames_dir:
return {'success': False, 'message': '缺少frames_dir'}
try:
os.makedirs(frames_dir, exist_ok=True)
except Exception as e:
return {'success': False, 'message': f'创建录制目录失败: {e}'}
if record_fps is None:
record_fps = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'fps', fallback='10'))
record_fps = max(1, int(record_fps))
self._recording_session_id = session_id
self._recording_frames_dir = frames_dir
self._recording_target_fps = record_fps
self._recording_last_ts = 0.0
self._recording_index = 0
self._recording_written = 0
self._recording_drop = 0
self._recording_stop_event.clear()
self._recording_enabled = True
if not self._recording_thread or not self._recording_thread.is_alive():
self._recording_thread = threading.Thread(
target=self._recording_writer_loop,
name=f"{self.device_id}-JpegWriter",
daemon=True
)
self._recording_thread.start()
return {'success': True, 'message': '相机JPEG录制已启动', 'device_id': self.device_id, 'fps': record_fps, 'frames_dir': frames_dir}
except Exception as e:
return {'success': False, 'message': str(e)}
def stop_jpeg_recording(self, session_id: Optional[str] = None) -> Dict[str, Any]:
try:
if session_id and self._recording_session_id and session_id != self._recording_session_id:
return {'success': False, 'message': 'session_id不匹配'}
self._recording_enabled = False
self._recording_session_id = None
frames_dir = self._recording_frames_dir
fps = self._recording_target_fps
written = int(self._recording_written)
dropped = int(self._recording_drop)
self._recording_frames_dir = None
self._recording_target_fps = None
self._recording_last_ts = 0.0
self._recording_stop_event.set()
if self._recording_thread and self._recording_thread.is_alive():
self._recording_thread.join(timeout=2.0)
self._recording_thread = None
while not self._recording_queue.empty():
try:
self._recording_queue.get_nowait()
except queue.Empty:
break
self._recording_stop_event.clear()
return {
'success': True,
'message': '相机JPEG录制已停止',
'device_id': self.device_id,
'frames_dir': frames_dir,
'fps': fps,
'frames_written': written,
'frames_dropped': dropped
}
except Exception as e:
return {'success': False, 'message': str(e)}
def _recording_writer_loop(self):
while not self._recording_stop_event.is_set():
try:
item = self._recording_queue.get(timeout=0.2)
except queue.Empty:
continue
try:
frames_dir, idx, jpeg_bytes = item
if not frames_dir or not jpeg_bytes:
continue
filename = f"frame_{idx:06d}.jpg"
fpath = os.path.join(frames_dir, filename)
with open(fpath, 'wb') as f:
f.write(jpeg_bytes)
self._recording_written += 1
except Exception:
self._recording_drop += 1
finally:
try:
self._recording_queue.task_done()
except Exception:
pass
def _maybe_enqueue_recording(self, timestamp: float, frame_bytes: bytes):
if not self._recording_enabled:
return
frames_dir = self._recording_frames_dir
target_fps = self._recording_target_fps
if not frames_dir or not target_fps or target_fps <= 0:
return
if self._recording_last_ts > 0:
min_interval = 1.0 / float(target_fps)
if (timestamp - self._recording_last_ts) < min_interval:
return
idx = self._recording_index
self._recording_index += 1
self._recording_last_ts = timestamp
try:
self._recording_queue.put_nowait((frames_dir, idx, frame_bytes))
except queue.Full:
self._recording_drop += 1
def _set_property_optimized(self, prop, value):
"""
@ -138,6 +291,19 @@ class CameraManager(BaseDevice):
return True
def set_connected(self, is_connected: bool):
"""
设置连接状态并触发回调
Args:
is_connected: 连接状态
"""
# 更新心跳时间,防止刚连接就被判定超时
if is_connected:
self.update_heartbeat()
super().set_connected(is_connected)
def initialize(self) -> bool:
"""
初始化相机设备
@ -206,6 +372,8 @@ class CameraManager(BaseDevice):
# 使用set_connected方法来正确启动连接监控线程
self.set_connected(True)
self._last_connected_state = True
# 更新心跳时间,防止连接监控线程刚启动就判定为超时
self.update_heartbeat()
self._device_info.update({
'device_index': self.device_index,
'resolution': f"{self.width}x{self.height}",
@ -373,6 +541,9 @@ class CameraManager(BaseDevice):
total_config_time = (time.time() - config_start) * 1000
# 若未进行性能优化,确保变量存在
optimization_time = locals().get('optimization_time', 0.0)
self.logger.info(f"相机配置完成 - 分辨率: {actual_width}x{actual_height}, FPS: {actual_fps}")
self.logger.info(f"配置耗时统计 - 缓冲区: {buffer_time:.1f}ms, 优化设置: {optimization_time:.1f}ms, 分辨率: {resolution_time:.1f}ms, 帧率: {fps_time:.1f}ms, 验证: {verification_time:.1f}ms, 总计: {total_config_time:.1f}ms")
self.logger.debug(f"配置详情 - 分辨率设置: {resolution_time:.1f}ms, FPS设置: {fps_time:.1f}ms, 验证: {verification_time:.1f}ms, 总计: {total_config_time:.1f}ms")
@ -474,6 +645,8 @@ class CameraManager(BaseDevice):
try:
self.is_streaming = True
# 更新心跳时间,确保流开始时不会被误判超时
self.update_heartbeat()
self.streaming_thread = threading.Thread(
target=self._streaming_worker,
name=f"Camera-{self.device_index}-Stream",
@ -613,23 +786,6 @@ class CameraManager(BaseDevice):
# 更新心跳时间,防止连接监控线程判定为超时
self.update_heartbeat()
# 保存原始帧到队列(用于录制)
try:
self.frame_queue.put_nowait({
'frame': frame.copy(),
'timestamp': time.time()
})
except queue.Full:
# 队列满时丢弃最旧的帧,添加新帧
try:
self.frame_queue.get_nowait() # 移除最旧的帧
self.frame_queue.put_nowait({
'frame': frame.copy(),
'timestamp': time.time()
})
except queue.Empty:
pass # 队列为空,忽略
# 处理帧(降采样以优化传输负载)
processed_frame = self._process_frame(frame)
@ -704,6 +860,7 @@ class CameraManager(BaseDevice):
# 转换为bytes再做base64减少中间numpy对象的长生命周期
frame_bytes = buffer.tobytes()
self._maybe_enqueue_recording(time.time(), frame_bytes)
frame_data = base64.b64encode(frame_bytes).decode('utf-8')
# 发送数据
@ -776,17 +933,19 @@ class CameraManager(BaseDevice):
Returns:
Dict[str, Any]: 设备状态信息
"""
status = super().get_status()
status.update({
return {
'device_type': 'camera',
'is_connected': self.is_connected,
'is_streaming': self.is_streaming,
'device_index': self.device_index,
'resolution': f"{self.width}x{self.height}",
'target_fps': self.fps,
'actual_fps': self.actual_fps,
'frame_count': self.frame_count,
'dropped_frames': self.dropped_frames,
'has_frame': self.last_frame is not None
})
return status
'has_frame': self.last_frame is not None,
'device_info': self.get_device_info()
}
def capture_image(self, save_path: Optional[str] = None) -> Optional[np.ndarray]:
"""
@ -852,8 +1011,9 @@ class CameraManager(BaseDevice):
# 获取最新配置
config = self.config_manager.get_device_config('camera')
# 获取最新配置(按设备名映射,已限制为 camera1/camera2
key = self.device_name
config = self.config_manager.get_device_config(key)
# 更新配置属性
self.device_index = config.get('device_index', 0)
@ -880,8 +1040,8 @@ class CameraManager(BaseDevice):
# 创建新队列
self.frame_cache = queue.Queue(maxsize=frame_cache_len)
# 更新设备信息
self.device_id = f"camera_{self.device_index}"
# 更新设备信息设备ID直接使用设备名
self.device_id = self.device_name
self.logger.info(f"相机配置重新加载成功 - 设备索引: {self.device_index}, 分辨率: {self.width}x{self.height}, FPS: {self.fps}")
return True
@ -899,7 +1059,7 @@ class CameraManager(BaseDevice):
"""
try:
if not self.is_connected:
self.logger.info("相机未连接,检查连接状态")
# self.logger.info("相机未连接,检查连接状态")
return False
# 尝试读取一帧
@ -908,7 +1068,7 @@ class CameraManager(BaseDevice):
self.logger.error("相机连接已断开,读取失败")
return False
self.logger.info("相机硬件连接正常")
# self.logger.info("相机硬件连接正常")
return True
except Exception as e:
@ -946,6 +1106,21 @@ class CameraManager(BaseDevice):
self.frame_queue.get_nowait()
except queue.Empty:
break
self._recording_enabled = False
self._recording_session_id = None
self._recording_frames_dir = None
self._recording_target_fps = None
self._recording_last_ts = 0.0
self._recording_stop_event.set()
if self._recording_thread and self._recording_thread.is_alive():
self._recording_thread.join(timeout=2.0)
self._recording_thread = None
while not self._recording_queue.empty():
try:
self._recording_queue.get_nowait()
except queue.Empty:
break
self.last_frame = None
@ -957,4 +1132,4 @@ class CameraManager(BaseDevice):

View File

@ -57,6 +57,10 @@ class DeviceCoordinator:
self.is_initialized = False
self.is_running = False
self.coordinator_lock = threading.RLock()
self._init_summary = {
'initialized_at': None,
'device_results': {},
}
# 监控线程
self.monitor_thread = None
@ -75,9 +79,11 @@ class DeviceCoordinator:
'device_errors': defaultdict(int),
'reconnect_attempts': defaultdict(int)
}
self._last_restart_ts = defaultdict(float)
self._restart_in_progress = defaultdict(bool)
# 线程池
self.executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="DeviceCoord")
self.executor = ThreadPoolExecutor(max_workers=8, thread_name_prefix="DeviceCoord")
self.logger.info("设备协调器初始化完成")
@ -113,19 +119,23 @@ class DeviceCoordinator:
# 注册Socket.IO命名空间
self._register_namespaces()
# 初始化设备
if not self._initialize_devices():
self.logger.warning("设备初始化失败,将以降级模式继续运行")
# 启动监控线程
self._start_monitor()
# 初始化设备(失败则降级继续)
init_ok = bool(self._initialize_devices())
self._init_summary['initialized_at'] = time.time()
if not init_ok:
self.logger.warning("设备初始化失败(没有任何设备初始化成功)")
self.is_initialized = False
return False
self.is_initialized = True
self.stats['start_time'] = time.time()
# 启动监控线程
self._start_monitor()
self.logger.info("设备协调器初始化成功")
self._emit_event('coordinator_initialized', {'devices': list(self.devices.keys())})
return True
except Exception as e:
@ -159,38 +169,48 @@ class DeviceCoordinator:
futures = []
# FemtoBolt深度相机
if self.device_configs.get('femtobolt', {}).get('enabled', False):
if self.device_configs.get('femtobolt', {}).get('enable', False):
future = self.executor.submit(self._init_femtobolt)
futures.append(('femtobolt', future))
# 普通相机
if self.device_configs.get('camera', {}).get('enabled', False):
future = self.executor.submit(self._init_camera)
futures.append(('camera', future))
# 普通相机初始化两个实例camera1 与 camera2
# camera1 使用 [CAMERA1] 配置camera2 使用 [CAMERA2](若不存在则回退为 device_index+1
if self.device_configs.get('camera1', {}).get('enable', False):
futures.append(('camera1', self.executor.submit(self._init_camera_by_name, 'camera1', 'CAMERA1')))
if self.device_configs.get('camera2', {}).get('enable', False):
futures.append(('camera2', self.executor.submit(self._init_camera_by_name, 'camera2', 'CAMERA2')))
# IMU传感器
if self.device_configs.get('imu', {}).get('enabled', False):
if self.device_configs.get('imu', {}).get('enable', False):
future = self.executor.submit(self._init_imu)
futures.append(('imu', future))
# 压力传感器
if self.device_configs.get('pressure', {}).get('enabled', False):
if self.device_configs.get('pressure', {}).get('enable', False):
future = self.executor.submit(self._init_pressure)
futures.append(('pressure', future))
# 遥控器
if self.device_configs.get('remote', {}).get('enable', False):
future = self.executor.submit(self._init_remote)
futures.append(('remote', future))
# 等待所有设备初始化完成
success_count = 0
for device_name, future in futures:
try:
result = future.result(timeout=30) # 30秒超时
timeout_s = 45 if device_name == 'imu' else 30
result = future.result(timeout=timeout_s)
self._init_summary['device_results'][device_name] = bool(result)
if result:
success_count += 1
self.logger.info(f"{device_name}设备初始化成功")
else:
self.logger.error(f"{device_name}设备初始化失败")
except Exception as e:
self._init_summary['device_results'][device_name] = False
self.logger.error(f"{device_name}设备初始化异常: {e}")
# 至少需要一个设备初始化成功
@ -204,22 +224,181 @@ class DeviceCoordinator:
except Exception as e:
self.logger.error(f"设备初始化失败: {e}")
return False
def get_enabled_devices(self) -> List[str]:
enabled = []
try:
for name, cfg in (self.device_configs or {}).items():
if isinstance(cfg, dict) and cfg.get('enable', False):
enabled.append(name)
except Exception:
pass
return enabled
def get_required_devices_for_detection(self) -> List[str]:
enabled = self.get_enabled_devices()
required = [d for d in enabled if d not in ('remote',)]
return required
def get_device_readiness(self, device_name: str) -> Dict[str, Any]:
enabled = bool(self.device_configs.get(device_name, {}).get('enable', False))
device = self.devices.get(device_name)
readiness = {
'device_name': device_name,
'enabled': enabled,
'exists': device is not None,
'initializing': False,
'is_connected': False,
'is_streaming': False,
'ready': False,
}
if not device:
return readiness
status = None
try:
if hasattr(device, 'get_status'):
status = device.get_status()
except Exception:
status = None
try:
readiness['initializing'] = bool(getattr(device, '_initializing', False))
except Exception:
readiness['initializing'] = False
try:
if isinstance(status, dict) and 'is_connected' in status:
readiness['is_connected'] = bool(status.get('is_connected'))
else:
readiness['is_connected'] = bool(getattr(device, 'is_connected', False))
except Exception:
readiness['is_connected'] = False
try:
if isinstance(status, dict) and 'is_streaming' in status:
readiness['is_streaming'] = bool(status.get('is_streaming'))
else:
readiness['is_streaming'] = bool(getattr(device, 'is_streaming', False))
except Exception:
readiness['is_streaming'] = False
ok = readiness['is_connected'] and (not readiness['initializing'])
readiness['ready'] = bool(ok)
return readiness
def get_readiness_snapshot(self, required_devices: Optional[List[str]] = None) -> Dict[str, Any]:
required = required_devices if required_devices is not None else self.get_required_devices_for_detection()
devices = {}
for name in required:
devices[name] = self.get_device_readiness(name)
all_ready = bool(self.is_initialized) and all(devices[name].get('ready', False) for name in devices)
return {
'coordinator': {
'is_initialized': bool(self.is_initialized),
'is_running': bool(self.is_running),
'enabled_devices': self.get_enabled_devices(),
'required_devices': required,
'init_summary': self._init_summary,
},
'devices': devices,
'all_ready': all_ready,
}
def wait_until_ready_for_detection(self, timeout_s: float = 10.0, poll_interval_s: float = 0.2) -> Dict[str, Any]:
deadline = time.time() + max(0.0, float(timeout_s))
last_snapshot = self.get_readiness_snapshot()
while time.time() < deadline:
last_snapshot = self.get_readiness_snapshot()
if last_snapshot.get('all_ready', False):
return last_snapshot
time.sleep(max(0.05, float(poll_interval_s)))
last_snapshot['timeout'] = True
last_snapshot['timeout_s'] = float(timeout_s)
return last_snapshot
def _init_camera(self) -> bool:
def _init_camera_by_name(self, device_name: str, section: str = 'CAMERA1') -> bool:
"""
初始化普通相机
按名称初始化相机支持 camera1/camera2 并覆盖配置段
Args:
device_name: 设备名称 'camera1''camera2'
section: 配置段名称'CAMERA1' 'CAMERA2'
Returns:
bool: 初始化是否成功
"""
try:
camera = CameraManager(self.socketio, self.config_manager)
self.devices['camera'] = camera
if camera.initialize():
# 构造实例覆盖配置:优先读取目标配置段,否则回退到 [CAMERA1]
cfg = {}
parser = getattr(self.config_manager, 'config', None)
base_cam = self.config_manager.get_device_config('camera1')
if parser and parser.has_section(section):
# 读取所有相关键
def get_opt(sec, key, fallback=None):
try:
return parser.get(sec, key)
except Exception:
return fallback
def get_int(sec, key, fallback=None):
try:
return parser.getint(sec, key)
except Exception:
return fallback
def get_bool(sec, key, fallback=None):
try:
return parser.getboolean(sec, key)
except Exception:
return fallback
enable = get_bool(section, 'enable', True)
if not enable:
self.logger.info(f"{device_name} 未启用,跳过初始化")
return False
# 填充覆盖项
idx2 = get_int(section, 'device_index', None)
if idx2 is not None:
cfg['device_index'] = idx2
w = get_int(section, 'width', None)
h = get_int(section, 'height', None)
f = get_int(section, 'fps', None)
buf = get_int(section, 'buffer_size', None)
fourcc = get_opt(section, 'fourcc', None)
backend = get_opt(section, 'backend', None)
if w is not None: cfg['width'] = w
if h is not None: cfg['height'] = h
if f is not None: cfg['fps'] = f
if buf is not None: cfg['buffer_size'] = buf
if fourcc is not None: cfg['fourcc'] = fourcc
if backend is not None: cfg['backend'] = backend
else:
# section 不存在时camera2 默认使用 device_index+1
if device_name.lower() == 'camera2':
cfg['device_index'] = int(base_cam.get('device_index', 0)) + 1
else:
cfg['device_index'] = int(base_cam.get('device_index', 0))
camera = CameraManager(self.socketio, self.config_manager, device_name=device_name, instance_config=cfg)
self.devices[device_name] = camera
if camera.initialize():
return True
return False
except Exception as e:
self.logger.error(f"初始化相机失败: {e}")
self.logger.error(f"初始化{device_name}失败: {e}")
return False
def _init_remote(self) -> bool:
"""
初始化串口遥控器
"""
try:
from .remote_control_manager import RemoteControlManager
remote = RemoteControlManager(self.socketio, self.config_manager)
self.devices['remote'] = remote
if not remote.initialize():
return False
if hasattr(remote, 'start_streaming'):
return bool(remote.start_streaming())
return True
except Exception as e:
self.logger.error(f"初始化遥控器失败: {e}")
return False
def _init_imu(self) -> bool:
@ -441,8 +620,8 @@ class DeviceCoordinator:
success_count = 0
for device_name, device in self.devices.items():
try:
# 对深度相机(femtobolt)和普通相机(camera)直接调用初始化和启动推流
if device_name in ['femtobolt', 'camera',"imu"]:
# 对深度相机(femtobolt)和普通相机(camera1/camera2)直接跳过连接监控
if device_name in ['femtobolt', 'camera1', 'camera2', "imu"]:
continue
if hasattr(device, '_start_connection_monitor'):
@ -475,19 +654,9 @@ class DeviceCoordinator:
success_count = 0
for device_name, device in self.devices.items():
try:
# 对深度相机(femtobolt)和普通相机(camera)直接调用停止推流
if device_name in ['femtobolt', 'camera',"imu"]:
self.logger.info(f"停止{device_name}设备推流")
# # 调用设备的cleanup方法清理资源,停止推流
# if hasattr(device, 'cleanup'):
# if device.cleanup():
# success_count += 1
# self.logger.info(f"{device_name}设备推流已停止")
# else:
# self.logger.warning(f"{device_name}设备推流停止失败")
# else:
# self.logger.warning(f"{device_name}设备不支持推流停止")
# 对深度相机(femtobolt)和普通相机(camera1/camera2)直接跳过连接监控停止
if device_name in ['femtobolt', 'camera1', 'camera2', "imu"]:
self.logger.info(f"停止{device_name}设备推流")
continue
if hasattr(device, '_stop_connection_monitor'):
@ -524,12 +693,22 @@ class DeviceCoordinator:
was_streaming = False
try:
if self._restart_in_progress[device_name]:
self.logger.warning(f"{device_name} 设备正在重启中,跳过重复重启请求")
return False
self._restart_in_progress[device_name] = True
self._last_restart_ts[device_name] = time.time()
self.logger.info(f"开始彻底重启设备: {device_name}")
# 第一步:检查并停止数据流
stop_start = time.time()
if hasattr(device, 'is_streaming'):
was_streaming = device.is_streaming
try:
if hasattr(device, 'get_status'):
was_streaming = bool((device.get_status() or {}).get('is_streaming', False))
elif hasattr(device, 'is_streaming'):
was_streaming = bool(device.is_streaming)
except Exception:
was_streaming = False
if hasattr(device, 'stop_streaming') and was_streaming:
self.logger.info(f"正在停止 {device_name} 设备推流...")
@ -546,6 +725,11 @@ class DeviceCoordinator:
# 第二步:断开连接并彻底清理资源
cleanup_start = time.time()
self.logger.info(f"正在彻底清理 {device_name} 设备...")
try:
if hasattr(device, '_init_abort'):
device._init_abort.set()
except Exception:
pass
# 断开连接但暂时不广播状态变化,避免重启过程中的状态冲突
if hasattr(device, 'disconnect'):
@ -583,7 +767,7 @@ class DeviceCoordinator:
self.logger.info(f"{device_name} 设备实例已销毁")
# 短暂等待,确保资源完全释放
time.sleep(0.2)
time.sleep(1.5 if device_name == 'imu' else 0.2)
destroy_time = (time.time() - destroy_start) * 1000
# 第四步:重新创建设备实例
@ -592,13 +776,58 @@ class DeviceCoordinator:
new_device = None
try:
# 根据设备类型重新创建实例
if device_name == 'camera':
# 根据设备类型重新创建实例(仅支持 camera1/camera2
if device_name in ('camera1', 'camera2'):
try:
from .camera_manager import CameraManager
except ImportError:
from camera_manager import CameraManager
new_device = CameraManager(self.socketio, self.config_manager)
# 为 camera1/camera2 构造实例配置
section = 'CAMERA1' if device_name == 'camera1' else 'CAMERA2'
cfg = {}
parser = getattr(self.config_manager, 'config', None)
base_cam = self.config_manager.get_device_config('camera1')
if parser and parser.has_section(section):
def get_opt(sec, key, fallback=None):
try:
return parser.get(sec, key)
except Exception:
return fallback
def get_int(sec, key, fallback=None):
try:
return parser.getint(sec, key)
except Exception:
return fallback
def get_bool(sec, key, fallback=None):
try:
return parser.getboolean(sec, key)
except Exception:
return fallback
enable = get_bool(section, 'enable', True)
if not enable:
raise Exception(f"{device_name} 未启用")
idx2 = get_int(section, 'device_index', None)
if idx2 is not None:
cfg['device_index'] = idx2
w = get_int(section, 'width', None)
h = get_int(section, 'height', None)
f = get_int(section, 'fps', None)
buf = get_int(section, 'buffer_size', None)
fourcc = get_opt(section, 'fourcc', None)
backend = get_opt(section, 'backend', None)
if w is not None: cfg['width'] = w
if h is not None: cfg['height'] = h
if f is not None: cfg['fps'] = f
if buf is not None: cfg['buffer_size'] = buf
if fourcc is not None: cfg['fourcc'] = fourcc
if backend is not None: cfg['backend'] = backend
else:
# section 不存在时camera2 默认使用 [CAMERA1] 的 device_index + 1
if device_name == 'camera2':
cfg['device_index'] = int(base_cam.get('device_index', 0)) + 1
else:
cfg['device_index'] = int(base_cam.get('device_index', 0))
new_device = CameraManager(self.socketio, self.config_manager, device_name=device_name, instance_config=cfg)
elif device_name == 'imu':
try:
from .imu_manager import IMUManager
@ -617,6 +846,12 @@ class DeviceCoordinator:
except ImportError:
from femtobolt_manager import FemtoBoltManager
new_device = FemtoBoltManager(self.socketio, self.config_manager)
elif device_name == 'remote':
try:
from .remote_control_manager import RemoteControlManager
except ImportError:
from remote_control_manager import RemoteControlManager
new_device = RemoteControlManager(self.socketio, self.config_manager)
else:
raise ValueError(f"未知的设备类型: {device_name}")
@ -646,13 +881,22 @@ class DeviceCoordinator:
if not new_device.initialize():
init_time = (time.time() - init_start) * 1000
self.logger.error(f"{device_name} 设备初始化失败 (耗时: {init_time:.1f}ms)")
# 初始化失败,从设备字典中移除
self.devices.pop(device_name, None)
return False
init_time = (time.time() - init_start) * 1000
self.logger.info(f"{device_name} 设备初始化成功 (耗时: {init_time:.1f}ms)")
if device_name == 'remote' and hasattr(new_device, 'start_streaming'):
self.logger.info(f"正在启动 {device_name} 设备推流...")
try:
if not new_device.start_streaming():
self.logger.error(f"启动 {device_name} 设备推流失败")
return False
was_streaming = True
except Exception as e:
self.logger.error(f"启动 {device_name} 推流异常: {e}")
return False
# 设备初始化成功后,确保状态广播正确
# 此时设备应该已经通过initialize()方法中的set_connected(True)触发了状态变化通知
# 但为了确保状态一致性,我们再次确认状态
@ -689,6 +933,8 @@ class DeviceCoordinator:
error_msg = f"彻底重启设备 {device_name} 异常: {e} (耗时: {total_time:.1f}ms)"
self.logger.error(error_msg)
return False
finally:
self._restart_in_progress[device_name] = False
def _start_monitor(self):
"""
@ -714,18 +960,29 @@ class DeviceCoordinator:
while self.is_initialized:
try:
# 检查设备健康状态
for device_name, device in self.devices.items():
for device_name, device in list(self.devices.items()):
try:
if self._restart_in_progress.get(device_name, False) or getattr(device, '_initializing', False):
continue
status = device.get_status()
if not status.get('is_connected', False):
self.logger.warning(f"设备 {device_name} 连接丢失")
self.stats['device_errors'][device_name] += 1
# 尝试重连
if self.stats['device_errors'][device_name] <= 3:
if self.stats['reconnect_attempts'][device_name] >= 3:
continue
now = time.time()
if now - self._last_restart_ts[device_name] >= 50.0:
self._last_restart_ts[device_name] = now
self.logger.info(f"尝试重连设备: {device_name}")
if self.restart_device(device_name):
self.stats['device_errors'][device_name] = 0
self.stats['reconnect_attempts'][device_name] = 0
else:
self.stats['reconnect_attempts'][device_name] += 1
if self.stats['reconnect_attempts'][device_name] >= 3:
self.logger.error(f"设备 {device_name} 重连失败已达3次停止自动重试")
except Exception as e:
self.logger.error(f"检查设备 {device_name} 状态异常: {e}")
@ -812,7 +1069,12 @@ class DeviceCoordinator:
self.executor.shutdown(wait=True)
# 清理Socket管理器
self.socket_manager.cleanup()
try:
self.socket_manager.cleanup_all()
except Exception:
# 兼容旧接口
if hasattr(self.socket_manager, 'cleanup'):
self.socket_manager.cleanup()
self.logger.info("设备协调器已关闭")
@ -837,7 +1099,7 @@ def test_restart_device(device_name=None):
Args:
device_name (str, optional): 指定要测试的设备名称如果为None则自动选择第一个可用设备
可选值: 'camera', 'imu', 'pressure', 'femtobolt'
可选值: 'camera1', 'camera2', 'imu', 'pressure', 'femtobolt'
"""
import time
import threading
@ -847,22 +1109,13 @@ def test_restart_device(device_name=None):
print("设备协调器重启功能测试")
print("=" * 60)
# 创建模拟的SocketIO和配置管理器
# 创建模拟的SocketIO(使用真实配置文件)
mock_socketio = Mock()
mock_config_manager = Mock()
# 模拟配置数据
mock_config_manager.get_device_config.return_value = {
'camera': {'enabled': True, 'device_id': 0, 'fps': 30},
'imu': {'enabled': True, 'device_type': 'mock'},
'pressure': {'enabled': True, 'device_type': 'mock'},
'femtobolt': {'enabled': False}
}
try:
# 创建设备协调器实例
print("1. 创建设备协调器...")
coordinator = DeviceCoordinator(mock_socketio, mock_config_manager)
coordinator = DeviceCoordinator(mock_socketio)
# 初始化设备协调器
print("2. 初始化设备协调器...")
@ -882,13 +1135,14 @@ def test_restart_device(device_name=None):
print("❌ 没有可用的设备进行测试")
return False
# 根据参数选择测试设备
# 根据参数选择测试设备(仅支持 camera1/camera2/imu/pressure/femtobolt
if device_name:
if device_name in available_devices:
allowed = {'camera1', 'camera2', 'imu', 'pressure', 'femtobolt'}
if device_name in available_devices and device_name in allowed:
test_device = device_name
print(f"3. 使用指定的测试设备: {test_device}")
else:
print(f"❌ 指定的设备 '{device_name}' 不存在")
print(f"❌ 指定的设备 '{device_name}' 不存在或不受支持")
print(f" 可用设备: {available_devices}")
return False
else:
@ -992,8 +1246,8 @@ if __name__ == "__main__":
)
# 执行测试
# 可选值: 'camera', 'imu', 'pressure', 'femtobolt'
success = test_restart_device('pressure')
# 可选值: 'camera1', 'camera2', 'imu', 'pressure', 'femtobolt'
success = test_restart_device('imu')
if success:
print("\n🎉 所有测试通过!")
@ -1005,4 +1259,4 @@ if __name__ == "__main__":
except Exception as e:
print(f"\n❌ 测试启动失败: {e}")
import traceback
traceback.print_exc()
traceback.print_exc()

View File

@ -0,0 +1,335 @@
# coding:UTF-8
import time
import bleak
import asyncio
import logging
# 设备实例 Device instance
class DeviceModel:
# region 属性 attribute
# 设备名称 deviceName
deviceName = "我的设备"
# 设备数据字典 Device Data Dictionary
deviceData = {}
# 设备是否开启
isOpen = False
# 临时数组 Temporary array
TempBytes = []
# endregion
def __init__(self, deviceName, BLEDevice, callback_method):
self.logger = logging.getLogger("device.imu.witmotion")
self.logger.info("初始化IMU设备模型")
# 设备名称(自定义) Device Name
self.deviceName = deviceName
self.BLEDevice = BLEDevice
self.client = None
self.writer_characteristic = None
self.isOpen = False
self.callback_method = callback_method
self.deviceData = {}
self._battery_ts = 0.0
@staticmethod
def _battery_percent_from_reg(reg_value: int) -> int:
try:
v = int(reg_value)
except Exception:
return 0
if v > 396:
return 100
if v >= 393:
return 90
if v >= 387:
return 75
if v >= 382:
return 60
if v >= 379:
return 50
if v >= 377:
return 40
if v >= 373:
return 30
if v >= 370:
return 20
if v >= 368:
return 15
if v >= 350:
return 10
if v >= 340:
return 5
return 0
# region 获取设备数据 Obtain device data
# 设置设备数据 Set device data
def set(self, key, value):
# 将设备数据存到键值 Saving device data to key values
self.deviceData[key] = value
# 获得设备数据 Obtain device data
def get(self, key):
# 从键值中获取数据没有则返回None Obtaining data from key values
if key in self.deviceData:
return self.deviceData[key]
else:
return None
# 删除设备数据 Delete device data
def remove(self, key):
# 删除设备键值
del self.deviceData[key]
# endregion
# 打开设备 open Device
async def openDevice(self):
start_ts = time.perf_counter()
self.logger.info("正在打开蓝牙IMU设备...")
connect_start = time.perf_counter()
async with bleak.BleakClient(self.BLEDevice, timeout=15) as client:
self.client = client
self.logger.info(f"蓝牙连接建立完成(耗时: {(time.perf_counter() - connect_start)*1000:.1f}ms")
self.isOpen = True
# 设备UUID常量 Device UUID constant
target_service_uuid = "0000ffe5-0000-1000-8000-00805f9a34fb"
target_characteristic_uuid_read = "0000ffe4-0000-1000-8000-00805f9a34fb"
target_characteristic_uuid_write = "0000ffe9-0000-1000-8000-00805f9a34fb"
notify_characteristic = None
self.logger.info("正在匹配服务...")
await asyncio.sleep(0.3)
services = []
for i in range(10):
tmp_services = None
get_services = getattr(client, 'get_services', None)
if callable(get_services):
try:
tmp_services = await get_services()
except Exception:
tmp_services = None
if not tmp_services:
backend = getattr(client, "_backend", None)
backend_get_services = getattr(backend, "get_services", None)
if callable(backend_get_services):
try:
tmp_services = await backend_get_services()
except Exception:
tmp_services = None
if not tmp_services:
tmp_services = getattr(client, 'services', None)
try:
services = list(tmp_services) if tmp_services else []
except Exception:
services = []
for service in services:
try:
svc_uuid = str(getattr(service, "uuid", "") or "").lower()
except Exception:
svc_uuid = ""
if svc_uuid == str(target_service_uuid).lower():
self.logger.info(f"匹配到服务: {service}")
self.logger.info("正在匹配特征...")
chars = []
try:
chars = list(getattr(service, "characteristics", None) or [])
except Exception:
chars = []
for characteristic in chars:
try:
chr_uuid = str(getattr(characteristic, "uuid", "") or "").lower()
except Exception:
chr_uuid = ""
if chr_uuid == str(target_characteristic_uuid_read).lower():
notify_characteristic = characteristic
if chr_uuid == str(target_characteristic_uuid_write).lower():
self.writer_characteristic = characteristic
if notify_characteristic:
break
if notify_characteristic:
break
await asyncio.sleep(0.2 + i * 0.1)
if notify_characteristic:
self.logger.info(f"匹配到特征: {notify_characteristic}")
# 设置通知以接收数据 Set up notifications to receive data
await client.start_notify(notify_characteristic.uuid, self.onDataReceived)
self.logger.info("开始接收姿态数据XYZ欧拉角")
self.logger.info(f"设备打开完成(耗时: {(time.perf_counter() - start_ts)*1000:.1f}ms")
# 保持连接打开 Keep connected and open
try:
while self.isOpen:
try:
if not bool(getattr(client, "is_connected", False)):
self.isOpen = False
break
except Exception:
self.isOpen = False
break
await asyncio.sleep(1)
except asyncio.CancelledError:
pass
finally:
# 在退出时停止通知 Stop notification on exit
try:
await client.stop_notify(notify_characteristic.uuid)
except Exception:
pass
else:
try:
svc_list = []
for s in services:
try:
svc_list.append(str(getattr(s, "uuid", "") or ""))
except Exception:
pass
self.logger.warning(f"未找到匹配的服务或特征,可用服务: {svc_list}")
except Exception:
self.logger.warning("未找到匹配的服务或特征")
raise RuntimeError("未找到匹配的服务或特征")
# 关闭设备 close Device
def closeDevice(self):
self.isOpen = False
self.logger.info("设备已关闭")
# region 数据解析 data analysis
# 串口数据处理 Serial port data processing
def onDataReceived(self, sender, data):
tempdata = bytes.fromhex(data.hex())
for var in tempdata:
self.TempBytes.append(var)
if len(self.TempBytes) == 1 and self.TempBytes[0] != 0x55:
del self.TempBytes[0]
continue
if len(self.TempBytes) == 20:
self.processData(self.TempBytes)
self.TempBytes.clear()
# 数据解析 data analysis
def processData(self, Bytes):
if Bytes[1] == 0x61:
AngX = self.getSignInt16(Bytes[15] << 8 | Bytes[14]) / 32768 * 180
AngY = self.getSignInt16(Bytes[17] << 8 | Bytes[16]) / 32768 * 180
AngZ = self.getSignInt16(Bytes[19] << 8 | Bytes[18]) / 32768 * 180
self.set("AngX", round(AngX, 3))
self.set("AngY", round(AngY, 3))
self.set("AngZ", round(AngZ, 3))
self.callback_method(self)
return
if Bytes[1] == 0x71:
start_reg = int(Bytes[2])
regs = []
for i in range(8):
base = 4 + i * 2
if base + 1 >= len(Bytes):
break
regs.append(int(Bytes[base]) | (int(Bytes[base + 1]) << 8))
for idx, val in enumerate(regs):
self.set(f"Reg_{start_reg + idx:02X}", val)
if regs and start_reg == 0x64:
raw = int(regs[0])
self.set("BatteryRaw", raw)
self.set("BatteryVoltage", round(raw / 100.0, 2))
self.set("BatteryPercent", int(self._battery_percent_from_reg(raw)))
self._battery_ts = time.time()
self.set("BatteryTS", self._battery_ts)
self.callback_method(self)
return
# 获得int16有符号数 Obtain int16 signed number
@staticmethod
def getSignInt16(num):
if num >= pow(2, 15):
num -= pow(2, 16)
return num
# endregion
# 发送串口数据 Sending serial port data
async def sendData(self, data):
try:
if self.client.is_connected and self.writer_characteristic is not None:
await self.client.write_gatt_char(self.writer_characteristic.uuid, bytes(data))
except Exception as ex:
self.logger.warning(f"发送数据失败: {ex}")
# 读取寄存器 read register
async def readReg(self, regAddr):
# 封装读取指令并向串口发送数据 Encapsulate read instructions and send data to the serial port
await self.sendData(self.get_readBytes(regAddr))
async def readBattery(self, timeout: float = 2.0):
if not bool(self.isOpen):
return None
client = getattr(self, "client", None)
if client is None or not bool(getattr(client, "is_connected", False)):
return None
prev_ts = float(self.deviceData.get("BatteryTS", 0.0) or 0.0)
await self.readReg(0x64)
deadline = time.time() + max(0.1, float(timeout))
while time.time() < deadline:
cur_ts = float(self.deviceData.get("BatteryTS", 0.0) or 0.0)
if cur_ts > prev_ts:
return {
"raw": self.deviceData.get("BatteryRaw"),
"voltage": self.deviceData.get("BatteryVoltage"),
"percent": self.deviceData.get("BatteryPercent"),
}
await asyncio.sleep(0.05)
return None
# 写入寄存器 Write Register
async def writeReg(self, regAddr, sValue):
# 解锁 unlock
self.unlock()
# 延迟100ms Delay 100ms
time.sleep(0.1)
# 封装写入指令并向串口发送数据
await self.sendData(self.get_writeBytes(regAddr, sValue))
# 延迟100ms Delay 100ms
time.sleep(0.1)
# 保存 save
self.save()
# 读取指令封装 Read instruction encapsulation
@staticmethod
def get_readBytes(regAddr):
# 初始化
tempBytes = [None] * 5
tempBytes[0] = 0xff
tempBytes[1] = 0xaa
tempBytes[2] = 0x27
tempBytes[3] = regAddr
tempBytes[4] = 0
return tempBytes
# 写入指令封装 Write instruction encapsulation
@staticmethod
def get_writeBytes(regAddr, rValue):
# 初始化
tempBytes = [None] * 5
tempBytes[0] = 0xff
tempBytes[1] = 0xaa
tempBytes[2] = regAddr
tempBytes[3] = rValue & 0xff
tempBytes[4] = rValue >> 8
return tempBytes
# 解锁 unlock
def unlock(self):
cmd = self.get_writeBytes(0x69, 0xb588)
self.sendData(cmd)
# 保存 save
def save(self):
cmd = self.get_writeBytes(0x00, 0x0000)
self.sendData(cmd)

View File

@ -249,21 +249,20 @@ class FemtoBoltManager(BaseDevice):
try:
# 设置图形背景色和边距
self.fig.patch.set_facecolor((50/255, 50/255, 50/255)) # 设置深灰色背景 rgb(50, 50, 50)
self.fig.patch.set_facecolor((38/255, 48/255, 64/255))
self.fig.tight_layout(pad=0) # 移除所有边距
# 清除之前的绘图
self.ax.clear()
self.ax.set_facecolor((50/255, 50/255, 50/255)) # 设置坐标区域背景色为黑色
self.ax.set_facecolor((38/255, 48/255, 64/255))
# 深度数据过滤与display_x.py完全一致
depth[depth > self.depth_range_max] = 0
depth[depth < self.depth_range_min] = 0
# 背景图(深灰色背景)
# 创建RGB格式的背景图确保颜色准确性
background_gray_value = 50 # RGB(50, 50, 50)
background = np.full((*depth.shape, 3), background_gray_value, dtype=np.uint8)
# 背景图(#263040
background = np.zeros((*depth.shape, 3), dtype=np.uint8)
background[:] = (38, 48, 64)
# 使用 np.ma.masked_equal() 来屏蔽深度图中的零值
depth = np.ma.masked_equal(depth, 0)
@ -940,8 +939,10 @@ class FemtoBoltManager(BaseDevice):
Returns:
Dict[str, Any]: 设备状态信息
"""
status = super().get_status()
status.update({
return {
'device_type': 'femtobolt',
'is_connected': self.is_connected,
'is_streaming': self.is_streaming,
'color_resolution': self.color_resolution,
'depth_mode': self.depth_mode,
'target_fps': self.fps,
@ -950,9 +951,9 @@ class FemtoBoltManager(BaseDevice):
'dropped_frames': self.dropped_frames,
'depth_range': f"{self.depth_range_min}-{self.depth_range_max}mm",
'has_depth_frame': self.last_depth_frame is not None,
'has_color_frame': self.last_color_frame is not None
})
return status
'has_color_frame': self.last_color_frame is not None,
'device_info': self.get_device_info()
}

File diff suppressed because it is too large Load Diff

View File

@ -1,649 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
IMU传感器管理器
负责IMU传感器的连接校准和头部姿态数据采集
"""
import serial
import threading
import time
import json
import numpy as np
from typing import Optional, Dict, Any, List, Tuple
import logging
from collections import deque
import struct
from datetime import datetime
try:
from .base_device import BaseDevice
from .utils.socket_manager import SocketManager
from .utils.config_manager import ConfigManager
except ImportError:
from base_device import BaseDevice
from utils.socket_manager import SocketManager
from utils.config_manager import ConfigManager
# 设置日志
logger = logging.getLogger(__name__)
class RealIMUDevice:
"""真实IMU设备通过串口读取姿态数据"""
def __init__(self, port, baudrate):
self.port = port
self.baudrate = baudrate
self.ser = None
self.buffer = bytearray()
self.calibration_data = None
self.head_pose_offset = {'rotation': 0, 'tilt': 0, 'pitch': 0}
self.last_data = {
'roll': 0.0,
'pitch': 0.0,
'yaw': 0.0,
'temperature': 25.0
}
logger.debug(f'RealIMUDevice 初始化: port={self.port}, baudrate={self.baudrate}')
self._connect()
def _connect(self):
try:
logger.debug(f'尝试打开串口: {self.port} @ {self.baudrate}')
self.ser = serial.Serial(self.port, self.baudrate, timeout=1)
if hasattr(self.ser, 'reset_input_buffer'):
try:
self.ser.reset_input_buffer()
logger.debug('已清空串口输入缓冲区')
except Exception as e:
logger.debug(f'重置串口输入缓冲区失败: {e}')
logger.info(f'IMU设备连接成功: {self.port} @ {self.baudrate}bps')
except Exception as e:
# logger.error(f'IMU设备连接失败: {e}', exc_info=True)
self.ser = None
def set_calibration(self, calibration: Dict[str, Any]):
self.calibration_data = calibration
if 'head_pose_offset' in calibration:
self.head_pose_offset = calibration['head_pose_offset']
logger.debug(f'应用IMU校准数据: {self.head_pose_offset}')
def apply_calibration(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
"""应用校准:将当前姿态减去初始偏移,得到相对于初始姿态的变化量"""
if not raw_data or 'head_pose' not in raw_data:
return raw_data
# 应用校准偏移
calibrated_data = raw_data.copy()
head_pose = raw_data['head_pose'].copy()
angle=head_pose['rotation'] - self.head_pose_offset['rotation']
# 减去基准值(零点偏移)
head_pose['rotation'] = ((angle + 180) % 360) - 180
head_pose['tilt'] = head_pose['tilt'] - self.head_pose_offset['tilt']
head_pose['pitch'] = head_pose['pitch'] - self.head_pose_offset['pitch']
calibrated_data['head_pose'] = head_pose
return calibrated_data
@staticmethod
def _checksum(data: bytes) -> int:
return sum(data[:-1]) & 0xFF
def _parse_packet(self, data: bytes) -> Optional[Dict[str, float]]:
if len(data) != 11:
logger.debug(f'无效数据包长度: {len(data)}')
return None
if data[0] != 0x55:
logger.debug(f'错误的包头: 0x{data[0]:02X}')
return None
if self._checksum(data) != data[-1]:
logger.debug(f'校验和错误: 期望{self._checksum(data):02X}, 实际{data[-1]:02X}')
return None
packet_type = data[1]
vals = [int.from_bytes(data[i:i+2], 'little', signed=True) for i in range(2, 10, 2)]
if packet_type == 0x53: # 姿态角单位0.01°
pitchl, rxl, yawl, temp = vals # 注意这里 vals 已经是有符号整数
# 使用第一段代码里的比例系数
k_angle = 180.0
roll = -round(rxl / 32768.0 * k_angle,2)
pitch = -round(pitchl / 32768.0 * k_angle,2)
yaw = -round(yawl / 32768.0 * k_angle,2)
temp = temp / 100.0
self.last_data = {
'roll': roll,
'pitch': pitch,
'yaw': yaw,
'temperature': temp
}
# print(f'解析姿态角包: roll={roll}, pitch={pitch}, yaw={yaw}, temp={temp}')
return self.last_data
else:
# logger.debug(f'忽略的数据包类型: 0x{packet_type:02X}')
return None
def read_data(self, apply_calibration: bool = True) -> Dict[str, Any]:
if not self.ser or not getattr(self.ser, 'is_open', False):
# logger.warning('IMU串口未连接尝试重新连接...')
self._connect()
return {
'head_pose': {
'rotation': self.last_data['yaw'],
'tilt': self.last_data['roll'],
'pitch': self.last_data['pitch']
},
'temperature': self.last_data['temperature'],
'timestamp': datetime.now().isoformat()
}
try:
bytes_waiting = self.ser.in_waiting
if bytes_waiting:
# logger.debug(f'串口缓冲区待读字节: {bytes_waiting}')
chunk = self.ser.read(bytes_waiting)
# logger.debug(f'读取到字节: {len(chunk)}')
self.buffer.extend(chunk)
while len(self.buffer) >= 11:
if self.buffer[0] != 0x55:
dropped = self.buffer.pop(0)
logger.debug(f'丢弃无效字节: 0x{dropped:02X}')
continue
packet = bytes(self.buffer[:11])
parsed = self._parse_packet(packet)
del self.buffer[:11]
if parsed is not None:
raw = {
'head_pose': {
'rotation': parsed['yaw'], # rotation = roll
'tilt': parsed['roll'], # tilt = yaw
'pitch': parsed['pitch'] # pitch = pitch
},
'temperature': parsed['temperature'],
'timestamp': datetime.now().isoformat()
}
# logger.debug(f'映射后的头部姿态: {raw}')
return self.apply_calibration(raw) if apply_calibration else raw
raw = {
'head_pose': {
'rotation': self.last_data['yaw'],
'tilt': self.last_data['roll'],
'pitch': self.last_data['pitch']
},
'temperature': self.last_data['temperature'],
'timestamp': datetime.now().isoformat()
}
return self.apply_calibration(raw) if apply_calibration else raw
except Exception as e:
logger.error(f'IMU数据读取异常: {e}', exc_info=True)
raw = {
'head_pose': {
'rotation': self.last_data['yaw'],
'tilt': self.last_data['roll'],
'pitch': self.last_data['pitch']
},
'temperature': self.last_data['temperature'],
'timestamp': datetime.now().isoformat()
}
return self.apply_calibration(raw) if apply_calibration else raw
def __del__(self):
try:
if self.ser and getattr(self.ser, 'is_open', False):
self.ser.close()
logger.info('IMU设备串口已关闭')
except Exception:
pass
class MockIMUDevice:
"""模拟IMU设备"""
def __init__(self):
self.noise_level = 0.1
self.calibration_data = None # 校准数据
self.head_pose_offset = {'rotation': 0, 'tilt': 0, 'pitch': 0} # 头部姿态零点偏移
def set_calibration(self, calibration: Dict[str, Any]):
"""设置校准数据"""
self.calibration_data = calibration
if 'head_pose_offset' in calibration:
self.head_pose_offset = calibration['head_pose_offset']
def apply_calibration(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
"""应用校准:将当前姿态减去初始偏移,得到相对姿态"""
if not raw_data or 'head_pose' not in raw_data:
return raw_data
calibrated_data = raw_data.copy()
head_pose = raw_data['head_pose'].copy()
head_pose['rotation'] = head_pose['rotation'] - self.head_pose_offset['rotation']
head_pose['tilt'] = head_pose['tilt'] - self.head_pose_offset['tilt']
head_pose['pitch'] = head_pose['pitch'] - self.head_pose_offset['pitch']
calibrated_data['head_pose'] = head_pose
return calibrated_data
def read_data(self, apply_calibration: bool = True) -> Dict[str, Any]:
"""读取IMU数据"""
# 生成头部姿态角度数据,角度范围(-90°, +90°)
# 使用正弦波模拟自然的头部运动,添加随机噪声
import time
current_time = time.time()
# 旋转角(左旋为负,右旋为正)
rotation_angle = 30 * np.sin(current_time * 0.5) + np.random.normal(0, self.noise_level * 5)
rotation_angle = np.clip(rotation_angle, -90, 90)
# 倾斜角(左倾为负,右倾为正)
tilt_angle = 20 * np.sin(current_time * 0.3 + np.pi/4) + np.random.normal(0, self.noise_level * 5)
tilt_angle = np.clip(tilt_angle, -90, 90)
# 俯仰角(俯角为负,仰角为正)
pitch_angle = 15 * np.sin(current_time * 0.7 + np.pi/2) + np.random.normal(0, self.noise_level * 5)
pitch_angle = np.clip(pitch_angle, -90, 90)
# 生成原始数据
raw_data = {
'head_pose': {
'rotation': rotation_angle, # 旋转角:左旋(-), 右旋(+)
'tilt': tilt_angle, # 倾斜角:左倾(-), 右倾(+)
'pitch': pitch_angle # 俯仰角:俯角(-), 仰角(+)
},
'timestamp': datetime.now().isoformat()
}
# 应用校准并返回
return self.apply_calibration(raw_data) if apply_calibration else raw_data
class IMUManager(BaseDevice):
"""IMU传感器管理器"""
def __init__(self, socketio, config_manager: Optional[ConfigManager] = None):
"""
初始化IMU管理器
Args:
socketio: SocketIO实例
config_manager: 配置管理器实例
"""
# 配置管理
self.config_manager = config_manager or ConfigManager()
config = self.config_manager.get_device_config('imu')
super().__init__("imu", config)
# 保存socketio实例
self._socketio = socketio
# 设备配置
self.port = config.get('port', 'COM7')
self.baudrate = config.get('baudrate', 9600)
self.device_type = config.get('device_type', 'mock') # 'real' 或 'mock'
self.use_mock = config.get('use_mock', False) # 保持向后兼容
# IMU设备实例
self.imu_device = None
# 推流相关
self.imu_streaming = False
self.imu_thread = None
# 统计信息
self.data_count = 0
self.error_count = 0
# 校准相关
self.is_calibrated = False
self.head_pose_offset = {'rotation': 0, 'tilt': 0, 'pitch': 0}
# 数据缓存
self.data_buffer = deque(maxlen=100)
self.last_valid_data = None
self.logger.info(f"IMU管理器初始化完成 - 端口: {self.port}, 设备类型: {self.device_type}")
def initialize(self) -> bool:
"""
初始化IMU设备
Returns:
bool: 初始化是否成功
"""
try:
self.logger.info(f"正在初始化IMU设备...")
# 使用构造函数中已加载的配置,避免并发读取配置文件
self.logger.info(f"使用已加载配置: port={self.port}, baudrate={self.baudrate}, device_type={self.device_type}")
# 根据配置选择真实设备或模拟设备
# 优先使用device_type配置如果没有则使用use_mock配置向后兼容
use_real_device = (self.device_type == 'real') or (not self.use_mock)
if use_real_device:
self.logger.info(f"使用真实IMU设备 - 端口: {self.port}, 波特率: {self.baudrate}")
self.imu_device = RealIMUDevice(self.port, self.baudrate)
# 检查真实设备是否连接成功
if self.imu_device.ser is None:
self.logger.error(f"IMU设备连接失败: 无法打开串口 {self.port}")
self.is_connected = False
self.imu_device = None
return False
else:
self.logger.info("使用模拟IMU设备")
self.imu_device = MockIMUDevice()
self.is_connected = True
self._device_info.update({
'port': self.port,
'baudrate': self.baudrate,
'use_mock': self.use_mock
})
self.logger.info("IMU初始化成功")
return True
except Exception as e:
self.logger.error(f"IMU初始化失败: {e}")
self.is_connected = False
self.imu_device = None
return False
def _quick_calibrate_imu(self) -> Dict[str, Any]:
"""
快速IMU零点校准以当前姿态为基准
Returns:
Dict[str, Any]: 校准结果
"""
try:
if not self.imu_device:
return {'status': 'error', 'error': 'IMU设备未初始化'}
self.logger.info('开始IMU快速零点校准...')
# 直接读取一次原始数据作为校准偏移量
raw_data = self.imu_device.read_data(apply_calibration=False)
if not raw_data or 'head_pose' not in raw_data:
return {'status': 'error', 'error': '无法读取IMU原始数据'}
# 使用当前姿态作为零点偏移
self.head_pose_offset = {
'rotation': raw_data['head_pose']['rotation'],
'tilt': raw_data['head_pose']['tilt'],
'pitch': raw_data['head_pose']['pitch']
}
# 应用校准到设备
calibration_data = {'head_pose_offset': self.head_pose_offset}
self.imu_device.set_calibration(calibration_data)
self.logger.info(f'IMU快速校准完成: {self.head_pose_offset}')
return {
'status': 'success',
'head_pose_offset': self.head_pose_offset
}
except Exception as e:
self.logger.error(f'IMU快速校准失败: {e}')
return {'status': 'error', 'error': str(e)}
def calibrate(self) -> bool:
"""
校准IMU传感器
Returns:
bool: 校准是否成功
"""
try:
if not self.is_connected or not self.imu_device:
if not self.initialize():
self.logger.error("IMU设备未连接")
return False
# 使用快速校准方法
result = self._quick_calibrate_imu()
if result['status'] == 'success':
self.is_calibrated = True
self.logger.info("IMU校准成功")
return True
else:
self.logger.error(f"IMU校准失败: {result.get('error', '未知错误')}")
return False
except Exception as e:
self.logger.error(f"IMU校准失败: {e}")
return False
def start_streaming(self) -> bool:
"""
开始IMU数据流
Args:
socketio: SocketIO实例用于数据推送
Returns:
bool: 启动是否成功
"""
try:
if not self.is_connected or not self.imu_device:
if not self.initialize():
return False
if self.imu_streaming:
self.logger.warning("IMU数据流已在运行")
return True
# 启动前进行快速校准
if not self.is_calibrated:
self.logger.info("启动前进行快速零点校准...")
self._quick_calibrate_imu()
self.imu_streaming = True
self.imu_thread = threading.Thread(target=self._imu_streaming_thread, daemon=True)
self.imu_thread.start()
self.logger.info("IMU数据流启动成功")
return True
except Exception as e:
self.logger.error(f"IMU数据流启动失败: {e}")
self.imu_streaming = False
return False
def stop_streaming(self) -> bool:
"""
停止IMU数据流
Returns:
bool: 停止是否成功
"""
try:
self.imu_streaming = False
if self.imu_thread and self.imu_thread.is_alive():
self.imu_thread.join(timeout=3.0)
self.logger.info("IMU数据流已停止")
return True
except Exception as e:
self.logger.error(f"停止IMU数据流失败: {e}")
return False
def _imu_streaming_thread(self):
"""
IMU数据流工作线程
"""
self.logger.info("IMU数据流工作线程启动")
while self.imu_streaming:
try:
if self.imu_device:
# 读取IMU数据
data = self.imu_device.read_data(apply_calibration=True)
if data:
# 缓存数据
# self.data_buffer.append(data)
# self.last_valid_data = data
# 发送数据到前端
if self._socketio:
self._socketio.emit('imu_data', data, namespace='/devices')
# 更新统计
self.data_count += 1
else:
self.error_count += 1
time.sleep(0.02) # 50Hz采样率
except Exception as e:
self.logger.error(f"IMU数据流处理异常: {e}")
self.error_count += 1
time.sleep(0.1)
self.logger.info("IMU数据流工作线程结束")
def get_status(self) -> Dict[str, Any]:
"""
获取设备状态
Returns:
Dict[str, Any]: 设备状态信息
"""
status = super().get_status()
status.update({
'port': self.port,
'baudrate': self.baudrate,
'is_streaming': self.imu_streaming,
'is_calibrated': self.is_calibrated,
'data_count': self.data_count,
'error_count': self.error_count,
'buffer_size': len(self.data_buffer),
'has_data': self.last_valid_data is not None,
'head_pose_offset': self.head_pose_offset,
'device_type': 'mock' if self.use_mock else 'real'
})
return status
def get_latest_data(self) -> Optional[Dict[str, float]]:
"""
获取最新的IMU数据
Returns:
Optional[Dict[str, float]]: 最新数据无数据返回None
"""
return self.last_valid_data.copy() if self.last_valid_data else None
def collect_head_pose_data(self, duration: int = 10) -> List[Dict[str, Any]]:
"""
收集头部姿态数据
Args:
duration: 收集时长
Returns:
List[Dict[str, Any]]: 收集到的数据列表
"""
collected_data = []
if not self.is_connected or not self.imu_device:
self.logger.error("IMU设备未连接")
return collected_data
self.logger.info(f"开始收集头部姿态数据,时长: {duration}")
start_time = time.time()
while time.time() - start_time < duration:
try:
data = self.imu_device.read_data(apply_calibration=True)
if data:
# 添加时间戳
data['timestamp'] = time.time()
collected_data.append(data)
time.sleep(0.02) # 50Hz采样率
except Exception as e:
self.logger.error(f"数据收集异常: {e}")
break
self.logger.info(f"头部姿态数据收集完成,共收集 {len(collected_data)} 个样本")
return collected_data
def disconnect(self):
"""
断开IMU设备连接
"""
try:
self.stop_streaming()
if self.imu_device:
self.imu_device = None
self.is_connected = False
self.logger.info("IMU设备已断开连接")
except Exception as e:
self.logger.error(f"断开IMU设备连接失败: {e}")
def reload_config(self) -> bool:
"""
重新加载设备配置
Returns:
bool: 重新加载是否成功
"""
try:
self.logger.info("正在重新加载IMU配置...")
# 获取最新配置
config = self.config_manager.get_device_config('imu')
# 更新配置属性
self.port = config.get('port', 'COM7')
self.baudrate = config.get('baudrate', 9600)
self.device_type = config.get('device_type', 'mock')
self.use_mock = config.get('use_mock', False)
# 更新数据缓存队列大小
buffer_size = config.get('buffer_size', 100)
if buffer_size != self.data_buffer.maxlen:
# 保存当前数据
current_data = list(self.data_buffer)
# 创建新缓冲区
self.data_buffer = deque(maxlen=buffer_size)
# 恢复数据(保留最新的数据)
for data in current_data[-buffer_size:]:
self.data_buffer.append(data)
self.logger.info(f"IMU配置重新加载成功 - 端口: {self.port}, 波特率: {self.baudrate}, 设备类型: {self.device_type}")
return True
except Exception as e:
self.logger.error(f"重新加载IMU配置失败: {e}")
return False
def cleanup(self):
"""
清理资源
"""
try:
self.disconnect()
# 清理缓冲区
self.data_buffer.clear()
# 重置状态
self.is_calibrated = False
self.last_valid_data = None
self.head_pose_offset = {'rotation': 0, 'tilt': 0, 'pitch': 0}
super().cleanup()
self.logger.info("IMU资源清理完成")
except Exception as e:
self.logger.error(f"清理IMU资源失败: {e}")

177
backend/devices/imu_test.py Normal file
View File

@ -0,0 +1,177 @@
import argparse
import asyncio
import time
from statistics import mean
import bleak
import device_model
async def find_device_by_address(address: str, timeout_s: float):
try:
return await bleak.BleakScanner.find_device_by_address(address, timeout=timeout_s)
except TypeError:
return await bleak.BleakScanner.find_device_by_address(address, cb=dict(use_bdaddr=False))
async def find_device_by_name(name: str, timeout_s: float):
scanner_fn = getattr(bleak.BleakScanner, "find_device_by_name", None)
if callable(scanner_fn):
return await scanner_fn(name, timeout=timeout_s)
devices = await bleak.BleakScanner.discover(timeout=timeout_s)
for d in devices:
if (getattr(d, "name", None) or "") == name:
return d
return None
async def run_trials(label: str, finder, runs: int, cooldown_s: float):
ok_times = []
fail = 0
for i in range(1, runs + 1):
start = time.perf_counter()
device = await finder()
ms = (time.perf_counter() - start) * 1000
if device is None:
fail += 1
print(f"[{label}] [{i:03d}] FAIL {ms:.1f}ms")
else:
addr = getattr(device, "address", None)
name = getattr(device, "name", None)
ok_times.append(ms)
print(f"[{label}] [{i:03d}] OK {ms:.1f}ms address={addr} name={name}")
if cooldown_s > 0:
await asyncio.sleep(cooldown_s)
if ok_times:
print(f"[{label}] runs={runs} success={len(ok_times)} fail={fail} avg={mean(ok_times):.1f}ms min={min(ok_times):.1f}ms max={max(ok_times):.1f}ms")
else:
print(f"[{label}] runs={runs} success=0 fail={fail}")
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--address", default="FA:E8:88:06:FE:F3")
parser.add_argument("--name", default="WT901BLE67")
parser.add_argument("--runs", type=int, default=10)
parser.add_argument("--timeout", type=float, default=30.0)
parser.add_argument("--cooldown", type=float, default=0.3)
parser.add_argument("--mode", choices=["mac", "name", "both", "isopen", "battery"], default="both")
return parser.parse_args()
async def main():
args = parse_args()
if args.mode == "isopen":
device = await find_device_by_name(args.name, args.timeout)
if device is None:
device = await find_device_by_address(args.address, args.timeout)
if device is None:
print("FAIL: 未发现设备")
return
addr = getattr(device, "address", None)
name = getattr(device, "name", None)
if args.address and addr and addr.lower() != args.address.lower():
print(f"FAIL: 发现设备地址不匹配 found={addr} expected={args.address}")
return
print(f"FOUND address={addr} name={name}")
first_frame = asyncio.Event()
def on_update(dm):
if not first_frame.is_set():
first_frame.set()
dm = device_model.DeviceModel("imu_test", device, on_update)
task = asyncio.create_task(dm.openDevice())
try:
await asyncio.wait_for(first_frame.wait(), timeout=30.0)
print(f"OPENED isOpen={dm.isOpen} client_connected={bool(getattr(getattr(dm, 'client', None), 'is_connected', False))}")
except Exception:
print(f"OPEN_TIMEOUT isOpen={dm.isOpen} client_connected={bool(getattr(getattr(dm, 'client', None), 'is_connected', False))}")
while True:
client_connected = bool(getattr(getattr(dm, "client", None), "is_connected", False))
print(f"STATUS isOpen={dm.isOpen} client_connected={client_connected}")
if not dm.isOpen:
break
await asyncio.sleep(1.0)
try:
await asyncio.wait_for(task, timeout=5.0)
except Exception:
try:
task.cancel()
except Exception:
pass
print("DONE")
return
if args.mode == "battery":
device = await find_device_by_name(args.name, args.timeout)
if device is None:
device = await find_device_by_address(args.address, args.timeout)
if device is None:
print("FAIL: 未发现设备")
return
addr = getattr(device, "address", None)
name = getattr(device, "name", None)
if args.address and addr and addr.lower() != args.address.lower():
print(f"FAIL: 发现设备地址不匹配 found={addr} expected={args.address}")
return
print(f"FOUND address={addr} name={name}")
first_frame = asyncio.Event()
def on_update(dm):
if not first_frame.is_set():
first_frame.set()
dm = device_model.DeviceModel("imu_test", device, on_update)
task = asyncio.create_task(dm.openDevice())
try:
await asyncio.wait_for(first_frame.wait(), timeout=30.0)
print("OPENED")
except Exception:
print("OPEN_TIMEOUT")
info = await dm.readBattery(timeout=3.0)
print(f"BATTERY {info}")
try:
dm.closeDevice()
except Exception:
pass
try:
await asyncio.wait_for(task, timeout=5.0)
except Exception:
try:
task.cancel()
except Exception:
pass
print("DONE")
return
if args.mode in ("mac", "both"):
await run_trials(
"mac",
lambda: find_device_by_address(args.address, args.timeout),
args.runs,
args.cooldown,
)
if args.mode in ("name", "both"):
await run_trials(
"name",
lambda: find_device_by_name(args.name, args.timeout),
args.runs,
args.cooldown,
)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -463,7 +463,193 @@ class RealPressureDevice:
"""析构函数,确保资源清理"""
self.close()
class MockPressureDevice:
def __init__(self, rows: int = 32, cols: int = 32, seed: Optional[int] = None):
self.rows = rows
self.cols = cols
self.is_connected = True
self._rng = np.random.RandomState(seed if seed is not None else (int(time.time()) & 0xFFFF))
self._phase = 0.0
def read_data(self) -> Dict[str, Any]:
try:
if not self.is_connected:
return self._get_empty_data()
raw_data = self._generate_raw_frame()
zones = self._calculate_foot_pressure_zones(raw_data)
image_base64 = self._generate_pressure_image(
zones['left_front'], zones['left_rear'], zones['right_front'], zones['right_rear'], raw_data
)
return {
'foot_pressure': {
'left_front': round(zones['left_front'], 2),
'left_rear': round(zones['left_rear'], 2),
'right_front': round(zones['right_front'], 2),
'right_rear': round(zones['right_rear'], 2),
'left_total': round(zones['left_total'], 2),
'right_total': round(zones['right_total'], 2)
},
'pressure_image': image_base64,
'timestamp': datetime.now().isoformat()
}
except Exception:
return self._get_empty_data()
def _generate_raw_frame(self) -> np.ndarray:
rows, cols = self.rows, self.cols
gy, gx = np.meshgrid(np.arange(rows), np.arange(cols), indexing='ij')
gy = gy.astype(np.float64)
gx = gx.astype(np.float64)
self._phase += 0.15
lf_cy = rows * 0.30 + 0.6 * np.sin(self._phase)
lf_cx = cols * 0.25 + 0.3 * np.cos(self._phase * 0.7)
lr_cy = rows * 0.75 + 0.5 * np.sin(self._phase * 0.8)
lr_cx = cols * 0.25 + 0.2 * np.sin(self._phase * 0.6)
rf_cy = rows * 0.30 + 0.6 * np.cos(self._phase * 0.9)
rf_cx = cols * 0.75 + 0.3 * np.sin(self._phase)
rr_cy = rows * 0.75 + 0.5 * np.cos(self._phase * 0.5)
rr_cx = cols * 0.75 + 0.2 * np.cos(self._phase * 0.4)
sy = rows * 0.10
sx = cols * 0.10
def gauss(cy: float, cx: float, amp: float) -> np.ndarray:
return amp * np.exp(-(((gy - cy) ** 2) / (2 * sy * sy) + ((gx - cx) ** 2) / (2 * sx * sx)))
lf = gauss(lf_cy, lf_cx, 300.0 + 120.0 * self._rng.rand())
lr = gauss(lr_cy, lr_cx, 280.0 + 120.0 * self._rng.rand())
rf = gauss(rf_cy, rf_cx, 300.0 + 120.0 * self._rng.rand())
rr = gauss(rr_cy, rr_cx, 280.0 + 120.0 * self._rng.rand())
base = lf + lr + rf + rr
noise = self._rng.normal(0.0, 5.0, size=(rows, cols))
frame = base + noise
frame = np.clip(frame, 0, 65535).astype(np.uint16)
return frame
def _calculate_foot_pressure_zones(self, raw_data: np.ndarray) -> Dict[str, Any]:
try:
rd = np.asarray(raw_data, dtype=np.float64)
rows, cols = rd.shape if rd.ndim == 2 else (0, 0)
if rows == 0 or cols == 0:
raise ValueError
mid_r = rows // 2
mid_c = cols // 2
left_front = float(np.sum(rd[:mid_r, :mid_c], dtype=np.float64))
left_rear = float(np.sum(rd[mid_r:, :mid_c], dtype=np.float64))
right_front = float(np.sum(rd[:mid_r, mid_c:], dtype=np.float64))
right_rear = float(np.sum(rd[mid_r:, mid_c:], dtype=np.float64))
left_total_abs = left_front + left_rear
right_total_abs = right_front + right_rear
total_abs = left_total_abs + right_total_abs
left_total_pct = float((left_total_abs / total_abs * 100) if total_abs > 0 else 0)
right_total_pct = float((right_total_abs / total_abs * 100) if total_abs > 0 else 0)
left_front_pct = float((left_front / total_abs * 100) if total_abs > 0 else 0)
left_rear_pct = float((left_rear / total_abs * 100) if total_abs > 0 else 0)
right_front_pct = float((right_front / total_abs * 100) if total_abs > 0 else 0)
right_rear_pct = float((right_rear / total_abs * 100) if total_abs > 0 else 0)
return {
'left_front': round(left_front_pct),
'left_rear': round(left_rear_pct),
'right_front': round(right_front_pct),
'right_rear': round(right_rear_pct),
'left_total': round(left_total_pct),
'right_total': round(right_total_pct),
'total_pressure': round(total_abs)
}
except Exception:
return {
'left_front': 0, 'left_rear': 0, 'right_front': 0, 'right_rear': 0,
'left_total': 0, 'right_total': 0, 'total_pressure': 0
}
def _generate_pressure_image(self, left_front: float, left_rear: float, right_front: float, right_rear: float, raw_data: Optional[np.ndarray] = None) -> str:
try:
if MATPLOTLIB_AVAILABLE and raw_data is not None:
return self._generate_heatmap_image(raw_data)
else:
return self._generate_simple_pressure_image(left_front, left_rear, right_front, right_rear)
except Exception:
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
def _generate_heatmap_image(self, raw_data: np.ndarray) -> str:
try:
# 底值阈值(小于等于该值的区域作为背景)
vmin = 10
# 归一化到 [0,255],避免 dmax==dmin 时除零
dmin, dmax = np.min(raw_data), np.max(raw_data)
norm = np.clip((raw_data - dmin) / max(dmax - dmin, 1) * 255, 0, 255).astype(np.uint8)
# 应用伪彩色JET以增强对比
heatmap = cv2.applyColorMap(norm, cv2.COLORMAP_JET)
# 将低值区域设置为背景色 #263040OpenCV 使用 BGR 通道顺序 -> (64, 48, 38)
heatmap[raw_data <= vmin] = (64, 48, 38)
# 放大显示,保持像素边界清晰
rows, cols = raw_data.shape
heatmap = cv2.resize(heatmap, (cols * 4, rows * 4), interpolation=cv2.INTER_NEAREST)
# 转换为 RGB 交给 PIL 编码
heatmap_rgb = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
from PIL import Image
buffer = BytesIO()
Image.fromarray(heatmap_rgb).save(buffer, format="PNG")
buffer.seek(0)
# 输出 data URL 便于前端直接显示
image_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
return f"data:image/png;base64,{image_base64}"
except Exception:
return self._generate_simple_pressure_image(0, 0, 0, 0)
def _generate_simple_pressure_image(self, left_front: float, left_rear: float, right_front: float, right_rear: float) -> str:
try:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.patches as patches
fig, ax = plt.subplots(1, 1, figsize=(6, 8))
ax.set_xlim(0, 10)
ax.set_ylim(0, 12)
ax.set_aspect('equal')
ax.axis('off')
m = max(left_front, left_rear, right_front, right_rear)
if m > 0:
lf_c = plt.cm.Reds(left_front / m)
lr_c = plt.cm.Reds(left_rear / m)
rf_c = plt.cm.Reds(right_front / m)
rr_c = plt.cm.Reds(right_rear / m)
else:
lf_c = lr_c = rf_c = rr_c = 'lightgray'
ax.add_patch(patches.Rectangle((1, 6), 2, 4, linewidth=1, edgecolor='black', facecolor=lf_c))
ax.add_patch(patches.Rectangle((1, 2), 2, 4, linewidth=1, edgecolor='black', facecolor=lr_c))
ax.add_patch(patches.Rectangle((7, 6), 2, 4, linewidth=1, edgecolor='black', facecolor=rf_c))
ax.add_patch(patches.Rectangle((7, 2), 2, 4, linewidth=1, edgecolor='black', facecolor=rr_c))
ax.text(2, 8, f'{left_front:.1f}', ha='center', va='center', fontsize=10, weight='bold')
ax.text(2, 4, f'{left_rear:.1f}', ha='center', va='center', fontsize=10, weight='bold')
ax.text(8, 8, f'{right_front:.1f}', ha='center', va='center', fontsize=10, weight='bold')
ax.text(8, 4, f'{right_rear:.1f}', ha='center', va='center', fontsize=10, weight='bold')
ax.text(2, 0.5, '左足', ha='center', va='center', fontsize=12, weight='bold')
ax.text(8, 0.5, '右足', ha='center', va='center', fontsize=12, weight='bold')
fig.patch.set_facecolor('black')
ax.set_facecolor('black')
buffer = BytesIO()
plt.savefig(buffer, format='png', bbox_inches='tight', dpi=100, facecolor='black')
buffer.seek(0)
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
plt.close(fig)
return f"data:image/png;base64,{image_base64}"
except Exception:
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
def _get_empty_data(self) -> Dict[str, Any]:
return {
'foot_pressure': {
'left_front': 0.0,
'left_rear': 0.0,
'right_front': 0.0,
'right_rear': 0.0,
'left_total': 0.0,
'right_total': 0.0
},
'pressure_image': "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
'timestamp': datetime.now().isoformat()
}
def close(self):
self.is_connected = False
class PressureManager(BaseDevice):
"""压力板管理器"""
@ -487,7 +673,7 @@ class PressureManager(BaseDevice):
# 设备实例
self.device = None
self.device_type = self.config.get('device_type', 'mock') # 'real' 或 'mock'
self.use_mock = bool(self.config.get('use_mock', False))
# 数据流相关
self.streaming_thread = None
@ -509,7 +695,7 @@ class PressureManager(BaseDevice):
self.read_fail_threshold = int(self.config.get('read_fail_threshold', 30))
self._last_connected_state = None # 去抖动状态广播
self.logger.info(f"压力板管理器初始化完成 - 设备类型: {self.device_type}")
self.logger.info(f"压力板管理器初始化完成 - use_mock: {self.use_mock}")
def initialize(self) -> bool:
"""
@ -518,26 +704,42 @@ class PressureManager(BaseDevice):
Returns:
bool: 初始化是否成功
"""
self._initializing = True
try:
self.logger.info(f"正在初始化压力板设备...")
# 使用构造函数中已加载的配置,避免并发读取配置文件
self.logger.info(f"使用已加载配置: device_type={self.device_type}, stream_interval={self.stream_interval}")
self.logger.info(f"使用已加载配置: use_mock={self.use_mock}, stream_interval={self.stream_interval}")
# 根据设备类型创建设备实例
if self.device_type == 'real':
if not self.use_mock:
self.device = RealPressureDevice()
else:
self.device = MockPressureDevice()
connected = False
try:
if self.use_mock:
connected = True
elif hasattr(self.device, 'is_connected'):
connected = bool(self.device.is_connected)
else:
connected = bool(self.check_hardware_connection())
except Exception:
connected = False
# 使用set_connected方法启动连接监控线程
self.set_connected(True)
self.set_connected(bool(connected))
self._device_info.update({
'device_type': self.device_type,
'device_type': 'mock' if self.use_mock else 'real',
'matrix_size': '4x4' if hasattr(self.device, 'rows') else 'unknown'
})
self.logger.info(f"压力板初始化成功 - 设备类型: {self.device_type}")
if not connected:
self.logger.warning("压力板初始化完成但硬件未连接")
return False
self.logger.info(f"压力板初始化成功 - use_mock: {self.use_mock}")
return True
except Exception as e:
@ -546,6 +748,8 @@ class PressureManager(BaseDevice):
self.set_connected(False)
self.device = None
return False
finally:
self._initializing = False
def start_streaming(self) -> bool:
@ -704,7 +908,7 @@ class PressureManager(BaseDevice):
Dict[str, Any]: 设备状态信息
"""
return {
'device_type': self.device_type,
'device_type': 'mock' if self.use_mock else 'real',
'is_connected': self.is_connected,
'is_streaming': self.is_streaming,
'is_calibrated': self.is_calibrated,
@ -780,14 +984,14 @@ class PressureManager(BaseDevice):
# 更新配置属性
self.config = new_config
self.device_type = new_config.get('device_type', 'mock')
self.use_mock = bool(new_config.get('use_mock', False))
self.stream_interval = new_config.get('stream_interval', 0.1)
# 动态更新重连参数
self.max_reconnect_attempts = int(new_config.get('max_reconnect_attempts', self.max_reconnect_attempts))
self.reconnect_delay = float(new_config.get('reconnect_delay', self.reconnect_delay))
self.read_fail_threshold = int(new_config.get('read_fail_threshold', self.read_fail_threshold))
self.logger.info(f"压力板配置重新加载成功 - 设备类型: {self.device_type}, 流间隔: {self.stream_interval}")
self.logger.info(f"压力板配置重新加载成功 - use_mock: {self.use_mock}, 流间隔: {self.stream_interval}")
return True
except Exception as e:
@ -899,4 +1103,4 @@ class PressureManager(BaseDevice):
self.disconnect()
self.logger.info("压力板设备资源清理完成")
except Exception as e:
self.logger.error(f"压力板设备资源清理失败: {e}")
self.logger.error(f"压力板设备资源清理失败: {e}")

View File

@ -0,0 +1,282 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import threading
import time
from typing import Optional, Dict, Any
import logging
import re
try:
import serial # pyserial
except Exception:
serial = None
try:
from .base_device import BaseDevice
from .utils.config_manager import ConfigManager
except ImportError:
from base_device import BaseDevice
from utils.config_manager import ConfigManager
def _modbus_crc16(data: bytes) -> int:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
if crc & 0x0001:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc & 0xFFFF
class RemoteControlManager(BaseDevice):
def __init__(self, socketio, config_manager: Optional[ConfigManager] = None):
self.config_manager = config_manager or ConfigManager()
port = self.config_manager.get_config_value('REMOTE', 'port', fallback='COM6')
baudrate = int(self.config_manager.get_config_value('REMOTE', 'baudrate', fallback='115200'))
timeout = float(self.config_manager.get_config_value('REMOTE', 'timeout', fallback='0.1'))
instance_config: Dict[str, Any] = {
'enable': True,
'port': port,
'baudrate': baudrate,
'timeout': timeout,
'strict_crc': bool(str(self.config_manager.get_config_value('REMOTE', 'strict_crc', fallback='False')).lower() == 'true'),
}
super().__init__("remote", instance_config)
self._socketio = socketio
self.logger = logging.getLogger(self.__class__.__name__)
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.bytesize = getattr(serial, 'EIGHTBITS', 8)
self.parity = getattr(serial, 'PARITY_NONE', 'N')
self.stopbits = getattr(serial, 'STOPBITS_ONE', 1)
self.strict_crc = instance_config['strict_crc']
self._ser: Optional[serial.Serial] = None
self._thread: Optional[threading.Thread] = None
self._running = False
self._buffer = bytearray()
def initialize(self) -> bool:
try:
self.logger.info(f"初始化遥控器串口: {self.port}, {self.baudrate}bps, 8N1")
self.set_connected(False)
self._device_info['initialized_at'] = time.time()
return True
except Exception as e:
self.logger.error(f"遥控器初始化失败: {e}")
self.set_connected(False)
return False
def start_streaming(self) -> bool:
try:
if self._running:
return True
if serial is None:
raise RuntimeError("pyserial 未安装或不可用")
self._ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
bytesize=self.bytesize,
parity=self.parity,
stopbits=self.stopbits,
timeout=self.timeout,
)
self.set_connected(True)
self.update_heartbeat()
self.is_streaming = True
self._running = True
self._thread = threading.Thread(target=self._worker_loop, daemon=True)
self._thread.start()
self.logger.info("遥控器串口监听已启动")
return True
except Exception as e:
self.logger.error(f"启动遥控器监听失败: {e}")
self._running = False
try:
if self._ser and self._ser.is_open:
self._ser.close()
except Exception:
pass
return False
def stop_streaming(self) -> bool:
try:
self._running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2.0)
if self._ser and self._ser.is_open:
self._ser.close()
self._ser = None
self.set_connected(False)
self.is_streaming = False
self.logger.info("遥控器串口监听已停止")
return True
except Exception as e:
self.logger.error(f"停止遥控器监听失败: {e}")
return False
def disconnect(self):
try:
self.stop_streaming()
self.set_connected(False)
except Exception as e:
self.logger.error(f"断开遥控器失败: {e}")
def reload_config(self) -> bool:
try:
self.logger.info("重新加载遥控器配置")
self.port = self.config_manager.get_config_value('REMOTE', 'port', fallback=self.port) or self.port
self.baudrate = int(self.config_manager.get_config_value('REMOTE', 'baudrate', fallback=self.baudrate))
self.timeout = float(self.config_manager.get_config_value('REMOTE', 'timeout', fallback=self.timeout))
self._device_info.update({
'port': self.port,
'baudrate': self.baudrate,
'timeout': self.timeout,
})
return True
except Exception as e:
self.logger.error(f"重新加载遥控器配置失败: {e}")
return False
def calibrate(self) -> Dict[str, Any]:
return {'status': 'success'}
def get_status(self) -> Dict[str, Any]:
return {
'name': self.device_name,
'port': self.port,
'baudrate': self.baudrate,
'timeout': self.timeout,
'is_connected': self.is_connected,
'is_streaming': self._running,
'has_serial': bool(self._ser and getattr(self._ser, 'is_open', False)),
'last_error': self._device_info.get('last_error')
}
def cleanup(self) -> None:
try:
self.stop_streaming()
except Exception:
pass
def check_hardware_connection(self) -> bool:
try:
return bool(self._ser and self._ser.is_open)
except Exception:
return False
def _emit_code(self, key_code: int):
code_hex = f"{key_code:02X}"
name_map = {
0x11: "start",
0x14: "stop",
0x13: "up",
0x15: "down",
0x12: "center",
0x0E: "power",
0x0F: "screenshot",
}
name = name_map.get(key_code, 'unknown')
payload = {
'code': code_hex,
'name': name,
'timestamp': time.time()
}
try:
msg = f"接收到遥控器按键: code={code_hex}, name={name}"
print(msg)
self.logger.info(msg)
except Exception:
pass
try:
if self._socketio:
self._socketio.emit('remote_control', payload, namespace='/devices')
except Exception as e:
self.logger.error(f"推送遥控器事件失败: {e}")
def _try_parse_frames(self):
while True:
if len(self._buffer) < 7:
break
idx = self._buffer.find(b'\x01\x04\x02\x00')
if idx >= 0 and len(self._buffer) - idx >= 7:
frame = bytes(self._buffer[idx:idx + 7])
calc_crc = _modbus_crc16(frame[:5])
recv_crc = frame[5] | (frame[6] << 8)
if calc_crc == recv_crc:
key_code = frame[4]
self._emit_code(key_code)
del self._buffer[:idx + 7]
continue
else:
if not self.strict_crc and len(frame) >= 7:
key_code = frame[4]
self._emit_code(key_code)
del self._buffer[:idx + 7]
continue
del self._buffer[idx:idx + 1]
continue
# ASCII HEX fallback
try:
text = bytes(self._buffer).decode(errors='ignore')
except Exception:
break
m = re.search(r'01\s*04\s*02\s*00\s*([0-9A-Fa-f]{2})\s*([0-9A-Fa-f]{2})\s*([0-9A-Fa-f]{2})', text)
if m:
k = int(m.group(1), 16)
crcL = int(m.group(2), 16)
crcH = int(m.group(3), 16)
calc = _modbus_crc16(bytes.fromhex(f'01 04 02 00 {m.group(1)}'))
recv = crcL | (crcH << 8)
if calc == recv:
self._emit_code(k)
elif not self.strict_crc:
self._emit_code(k)
self._buffer.clear()
break
# no header; trim
del self._buffer[:max(0, len(self._buffer) - 3)]
break
def _worker_loop(self):
self.logger.info("遥控器串口线程启动")
last_rx_ts = time.time()
while self._running:
try:
if not self._ser or not self._ser.is_open:
time.sleep(0.05)
continue
chunk = self._ser.read(64)
self.update_heartbeat()
if chunk:
try:
hexstr = ' '.join(f'{b:02X}' for b in chunk)
except Exception:
pass
self._buffer.extend(chunk)
self._try_parse_frames()
last_rx_ts = time.time()
else:
time.sleep(0.01)
if time.time() - last_rx_ts > 5.0:
self.logger.debug("遥控器串口暂无数据")
except Exception as e:
self.logger.error(f"遥控器串口读取异常: {e}")
try:
if self._ser and self._ser.is_open:
self._ser.close()
except Exception:
pass
self._ser = None
self.set_connected(False)
self.is_streaming = False
self._running = False
time.sleep(0.1)
self.logger.info("遥控器串口线程结束")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -84,7 +84,8 @@ class DeviceTestServer:
# 设备管理器和模拟数据生成器
self.device_managers = {
'camera': CameraManager(self.socketio, self.config_manager),
'camera1': CameraManager(self.socketio, self.config_manager, device_name='camera1'),
'camera2': CameraManager(self.socketio, self.config_manager, device_name='camera2'),
'femtobolt': FemtoBoltManager(self.socketio, self.config_manager),
'imu': IMUManager(self.socketio, self.config_manager),
'pressure': PressureManager(self.socketio, self.config_manager)
@ -340,7 +341,8 @@ class DeviceTestServer:
def _get_event_name(self, device_name: str) -> str:
"""获取设备对应的事件名称"""
event_map = {
'camera': 'camera_frame',
'camera1': 'camera_frame',
'camera2': 'camera_frame',
'femtobolt': 'femtobolt_frame',
'imu': 'imu_data',
'pressure': 'pressure_data'

View File

@ -14,7 +14,7 @@
}
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
font-family: 'Noto Sans SC';
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
color: #ffffff;
min-height: 100vh;

View File

@ -1,227 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
配置API测试脚本
用于测试设备配置HTTP API的功能
"""
import requests
import json
import time
class ConfigAPITester:
"""配置API测试器"""
def __init__(self, base_url="http://localhost:5002"):
"""
初始化测试器
Args:
base_url: API基础URL
"""
self.base_url = base_url
self.api_url = f"{base_url}/api/config"
def test_get_all_configs(self):
"""测试获取所有设备配置"""
print("\n=== 测试获取所有设备配置 ===")
try:
response = requests.get(f"{self.api_url}/devices")
result = response.json()
if result['success']:
print("✓ 获取所有设备配置成功")
print(json.dumps(result['data'], indent=2, ensure_ascii=False))
else:
print(f"✗ 获取失败: {result['message']}")
except Exception as e:
print(f"✗ 请求异常: {e}")
def test_get_single_config(self, device_name):
"""测试获取单个设备配置"""
print(f"\n=== 测试获取{device_name}设备配置 ===")
try:
response = requests.get(f"{self.api_url}/devices/{device_name}")
result = response.json()
if result['success']:
print(f"✓ 获取{device_name}配置成功")
print(json.dumps(result['data'], indent=2, ensure_ascii=False))
else:
print(f"✗ 获取失败: {result['message']}")
except Exception as e:
print(f"✗ 请求异常: {e}")
def test_set_imu_config(self):
"""测试设置IMU配置"""
print("\n=== 测试设置IMU配置 ===")
try:
data = {
"device_type": "real",
"port": "COM6",
"baudrate": 9600
}
response = requests.post(f"{self.api_url}/devices/imu", json=data)
result = response.json()
if result['success']:
print("✓ 设置IMU配置成功")
print(f"消息: {result['message']}")
print("更新后的配置:")
print(json.dumps(result['config'], indent=2, ensure_ascii=False))
else:
print(f"✗ 设置失败: {result['message']}")
except Exception as e:
print(f"✗ 请求异常: {e}")
def test_set_pressure_config(self):
"""测试设置压力板配置"""
print("\n=== 测试设置压力板配置 ===")
try:
data = {
"device_type": "real",
"use_mock": False,
"port": "COM5",
"baudrate": 115200
}
response = requests.post(f"{self.api_url}/devices/pressure", json=data)
result = response.json()
if result['success']:
print("✓ 设置压力板配置成功")
print(f"消息: {result['message']}")
print("更新后的配置:")
print(json.dumps(result['config'], indent=2, ensure_ascii=False))
else:
print(f"✗ 设置失败: {result['message']}")
except Exception as e:
print(f"✗ 请求异常: {e}")
def test_set_camera_config(self):
"""测试设置相机配置"""
print("\n=== 测试设置相机配置 ===")
try:
data = {
"device_index": 0,
"width": 1280,
"height": 720,
"fps": 30
}
response = requests.post(f"{self.api_url}/devices/camera", json=data)
result = response.json()
if result['success']:
print("✓ 设置相机配置成功")
print(f"消息: {result['message']}")
print("更新后的配置:")
print(json.dumps(result['config'], indent=2, ensure_ascii=False))
else:
print(f"✗ 设置失败: {result['message']}")
except Exception as e:
print(f"✗ 请求异常: {e}")
def test_set_femtobolt_config(self):
"""测试设置FemtoBolt配置"""
print("\n=== 测试设置FemtoBolt配置 ===")
try:
data = {
"color_resolution": "1080P",
"depth_mode": "NFOV_UNBINNED",
"fps": 30,
"depth_range_min": 1200,
"depth_range_max": 1500
}
response = requests.post(f"{self.api_url}/devices/femtobolt", json=data)
result = response.json()
if result['success']:
print("✓ 设置FemtoBolt配置成功")
print(f"消息: {result['message']}")
print("更新后的配置:")
print(json.dumps(result['config'], indent=2, ensure_ascii=False))
else:
print(f"✗ 设置失败: {result['message']}")
except Exception as e:
print(f"✗ 请求异常: {e}")
def test_validate_config(self):
"""测试验证配置"""
print("\n=== 测试验证配置 ===")
try:
response = requests.get(f"{self.api_url}/validate")
result = response.json()
if result['success']:
print("✓ 配置验证成功")
validation_result = result['data']
print(f"配置有效性: {validation_result['valid']}")
if validation_result['errors']:
print(f"错误: {validation_result['errors']}")
if validation_result['warnings']:
print(f"警告: {validation_result['warnings']}")
else:
print(f"✗ 验证失败: {result['message']}")
except Exception as e:
print(f"✗ 请求异常: {e}")
def test_reload_config(self):
"""测试重新加载配置"""
print("\n=== 测试重新加载配置 ===")
try:
response = requests.post(f"{self.api_url}/reload")
result = response.json()
if result['success']:
print("✓ 重新加载配置成功")
print(f"消息: {result['message']}")
else:
print(f"✗ 重新加载失败: {result['message']}")
except Exception as e:
print(f"✗ 请求异常: {e}")
def run_all_tests(self):
"""运行所有测试"""
print("开始配置API功能测试...")
print(f"API地址: {self.api_url}")
# 等待服务启动
print("\n等待API服务启动...")
time.sleep(2)
# 运行测试
self.test_get_all_configs()
# 测试获取单个设备配置
for device in ['imu', 'pressure', 'camera', 'femtobolt']:
self.test_get_single_config(device)
# 测试设置配置
self.test_set_imu_config()
self.test_set_pressure_config()
self.test_set_camera_config()
self.test_set_femtobolt_config()
# 测试其他功能
self.test_validate_config()
self.test_reload_config()
print("\n=== 测试完成 ===")
if __name__ == "__main__":
# 创建测试器并运行测试
tester = ConfigAPITester()
tester.run_all_tests()

View File

@ -96,14 +96,16 @@ class ConfigManager:
# 默认设备配置
self.config['DEVICES'] = {
'imu_port': 'COM7',
'imu_baudrate': '9600',
'imu_enable': 'False',
'imu_use_mock': 'False',
'imu_ble_name': '',
'imu_mac_address': '',
'pressure_port': 'COM8',
'pressure_baudrate': '115200'
}
# 默认相机配置
self.config['CAMERA'] = {
# 默认相机1配置
self.config['CAMERA1'] = {
'device_index': '0',
'width': '1280',
'height': '720',
@ -111,6 +113,15 @@ class ConfigManager:
'backend': 'directshow'
}
# 默认相机2配置
self.config['CAMERA2'] = {
'device_index': '1',
'width': '1280',
'height': '720',
'fps': '30',
'backend': 'directshow'
}
# 默认FemtoBolt配置
self.config['FEMTOBOLT'] = {
'color_resolution': '1080P',
@ -134,7 +145,7 @@ class ConfigManager:
获取设备配置
Args:
device_name: 设备名称 (camera, femtobolt, imu, pressure)
device_name: 设备名称 (camera1, camera2, femtobolt, imu, pressure, remote)
Returns:
Dict[str, Any]: 设备配置字典
@ -144,14 +155,18 @@ class ConfigManager:
config = {}
if device_name == 'camera':
config = self._get_camera_config()
if device_name == 'camera1':
config = self._get_camera1_config()
elif device_name == 'camera2':
config = self._get_camera2_config()
elif device_name == 'femtobolt':
config = self._get_femtobolt_config()
elif device_name == 'imu':
config = self._get_imu_config()
elif device_name == 'pressure':
config = self._get_pressure_config()
elif device_name == 'remote':
config = self._get_remote_config()
else:
self.logger.warning(f"未知设备类型: {device_name}")
@ -159,7 +174,7 @@ class ConfigManager:
self._device_configs[device_name] = config
return config.copy()
def _get_camera_config(self) -> Dict[str, Any]:
def _get_camera1_config(self) -> Dict[str, Any]:
"""
获取相机配置
@ -167,16 +182,32 @@ class ConfigManager:
Dict[str, Any]: 相机配置
"""
return {
'enabled': self.config.getboolean('CAMERA', 'enabled', fallback=True),
'device_index': self.config.getint('CAMERA', 'device_index', fallback=0),
'width': self.config.getint('CAMERA', 'width', fallback=1280),
'height': self.config.getint('CAMERA', 'height', fallback=720),
'fps': self.config.getint('CAMERA', 'fps', fallback=30),
'buffer_size': self.config.getint('CAMERA', 'buffer_size', fallback=1),
'fourcc': self.config.get('CAMERA', 'fourcc', fallback='MJPG'),
'backend': self.config.get('CAMERA', 'backend', fallback='directshow')
'enable': self.config.getboolean('CAMERA1', 'enable', fallback=False),
'device_index': self.config.getint('CAMERA1', 'device_index', fallback=0),
'width': self.config.getint('CAMERA1', 'width', fallback=1280),
'height': self.config.getint('CAMERA1', 'height', fallback=720),
'fps': self.config.getint('CAMERA1', 'fps', fallback=30),
'buffer_size': self.config.getint('CAMERA1', 'buffer_size', fallback=1),
'fourcc': self.config.get('CAMERA1', 'fourcc', fallback='MJPG'),
'backend': self.config.get('CAMERA1', 'backend', fallback='directshow')
}
def _get_camera2_config(self) -> Dict[str, Any]:
"""
获取相机配置
Returns:
Dict[str, Any]:
"""
return {
'enable': self.config.getboolean('CAMERA2', 'enable', fallback=False),
'device_index': self.config.getint('CAMERA2', 'device_index', fallback=0),
'width': self.config.getint('CAMERA2', 'width', fallback=1280),
'height': self.config.getint('CAMERA2', 'height', fallback=720),
'fps': self.config.getint('CAMERA2', 'fps', fallback=30),
'buffer_size': self.config.getint('CAMERA2', 'buffer_size', fallback=1),
'fourcc': self.config.get('CAMERA2', 'fourcc', fallback='MJPG'),
'backend': self.config.get('CAMERA2', 'backend', fallback='directshow')
}
def _get_femtobolt_config(self) -> Dict[str, Any]:
"""
获取FemtoBolt配置
@ -185,7 +216,7 @@ class ConfigManager:
Dict[str, Any]: FemtoBolt配置
"""
return {
'enabled': self.config.getboolean('FEMTOBOLT', 'enabled', fallback=True),
'enable': self.config.getboolean('FEMTOBOLT', 'enable', fallback=False),
'algorithm_type': self.config.get('FEMTOBOLT', 'algorithm_type', fallback='opencv'),
'color_resolution': self.config.get('FEMTOBOLT', 'color_resolution', fallback='1080P'),
'depth_mode': self.config.get('FEMTOBOLT', 'depth_mode', fallback='NFOV_UNBINNED'),
@ -203,13 +234,10 @@ class ConfigManager:
Dict[str, Any]: IMU配置
"""
return {
'enabled': self.config.getboolean('DEVICES', 'imu_enabled', fallback=True),
'device_type': self.config.get('DEVICES', 'imu_device_type', fallback='mock'),
'port': self.config.get('DEVICES', 'imu_port', fallback='COM7'),
'baudrate': self.config.getint('DEVICES', 'imu_baudrate', fallback=9600),
'timeout': self.config.getfloat('DEVICES', 'imu_timeout', fallback=1.0),
'calibration_samples': self.config.getint('DEVICES', 'imu_calibration_samples', fallback=100),
'mac_address': self.config.get('DEVICES', 'imu_mac_address', fallback='ef:3c:1a:0a:fe:02'),
'enable': self.config.getboolean('DEVICES', 'imu_enable', fallback=False),
'use_mock': self.config.getboolean('DEVICES', 'imu_use_mock', fallback=False),
'ble_name': self.config.get('DEVICES', 'imu_ble_name', fallback=''),
'mac_address': self.config.get('DEVICES', 'imu_mac_address', fallback='FA:E8:88:06:FE:F3'),
}
def _get_pressure_config(self) -> Dict[str, Any]:
@ -220,12 +248,25 @@ class ConfigManager:
Dict[str, Any]: 压力传感器配置
"""
return {
'enabled': self.config.getboolean('DEVICES', 'pressure_enabled', fallback=True),
'device_type': self.config.get('DEVICES', 'pressure_device_type', fallback='mock'),
'enable': self.config.getboolean('DEVICES', 'pressure_enable', fallback=False),
'use_mock': self.config.getboolean('DEVICES', 'pressure_use_mock', fallback=False),
'port': self.config.get('DEVICES', 'pressure_port', fallback='COM8'),
'baudrate': self.config.getint('DEVICES', 'pressure_baudrate', fallback=115200),
'timeout': self.config.getfloat('DEVICES', 'pressure_timeout', fallback=1.0),
'calibration_samples': self.config.getint('DEVICES', 'pressure_calibration_samples', fallback=50)
'baudrate': self.config.getint('DEVICES', 'pressure_baudrate', fallback=115200)
}
def _get_remote_config(self) -> Dict[str, Any]:
"""
获取远程控制配置
Returns:
Dict[str, Any]: 远程控制配置
"""
return {
'enable': self.config.getboolean('REMOTE', 'enable', fallback=False),
'port': self.config.get('REMOTE', 'port', fallback='COM6'),
'baudrate': self.config.getint('REMOTE', 'baudrate', fallback=115200),
'timeout': self.config.getfloat('REMOTE', 'timeout', fallback=0.1),
'strict_crc': self.config.getboolean('REMOTE', 'strict_crc', fallback=False)
}
def get_system_config(self) -> Dict[str, Any]:
@ -332,7 +373,7 @@ class ConfigManager:
warnings = []
# 验证必需的配置段
required_sections = ['DEVICES', 'CAMERA', 'FEMTOBOLT', 'SYSTEM']
required_sections = ['DEVICES', 'CAMERA1', 'CAMERA2', 'FEMTOBOLT', 'REMOTE']
for section in required_sections:
if not self.config.has_section(section):
errors.append(f"缺少必需的配置段: {section}")
@ -340,8 +381,8 @@ class ConfigManager:
# 验证设备配置
try:
imu_config = self.get_device_config('imu')
if not imu_config.get('port'):
warnings.append("IMU串口未配置")
if imu_config.get('enable') and not imu_config.get('use_mock') and not imu_config.get('mac_address'):
warnings.append("IMU未配置MAC地址")
except Exception as e:
errors.append(f"IMU配置验证失败: {e}")
@ -351,185 +392,7 @@ class ConfigManager:
'valid': len(errors) == 0
}
# HTTP接口设备参数设置方法
def set_imu_config(self, config_data: Dict[str, Any]) -> Dict[str, Any]:
"""
设置IMU设备配置
Args:
config_data: IMU配置数据
{
'device_type': 'real' | 'mock' | 'ble',
'port': 'COM6',
'baudrate': 9600,
'mac_address': 'ef:3c:1a:0a:fe:02'
}
Returns:
Dict[str, Any]: 设置结果
"""
try:
# 验证必需参数
if 'device_type' in config_data:
self.set_config_value('DEVICES', 'imu_device_type', config_data['device_type'])
if 'port' in config_data:
self.set_config_value('DEVICES', 'imu_port', config_data['port'])
if 'baudrate' in config_data:
self.set_config_value('DEVICES', 'imu_baudrate', str(config_data['baudrate']))
if 'mac_address' in config_data:
self.set_config_value('DEVICES', 'imu_mac_address', config_data['mac_address'])
# 保存配置
self.save_config()
self.logger.info(f"IMU配置已更新: {config_data}")
return {
'success': True,
'message': 'IMU配置更新成功',
'config': self.get_device_config('imu')
}
except Exception as e:
self.logger.error(f"设置IMU配置失败: {e}")
return {
'success': False,
'message': f'设置IMU配置失败: {str(e)}'
}
def set_pressure_config(self, config_data: Dict[str, Any]) -> Dict[str, Any]:
"""
设置压力板设备配置
Args:
config_data: 压力板配置数据
{
'device_type': 'real' | 'mock',
'use_mock': False,
'port': 'COM5',
'baudrate': 115200
}
Returns:
Dict[str, Any]: 设置结果
"""
try:
# 验证必需参数
if 'device_type' in config_data:
self.set_config_value('DEVICES', 'pressure_device_type', config_data['device_type'])
if 'use_mock' in config_data:
self.set_config_value('DEVICES', 'pressure_use_mock', str(config_data['use_mock']))
if 'port' in config_data:
self.set_config_value('DEVICES', 'pressure_port', config_data['port'])
if 'baudrate' in config_data:
self.set_config_value('DEVICES', 'pressure_baudrate', str(config_data['baudrate']))
# 保存配置
self.save_config()
self.logger.info(f"压力板配置已更新: {config_data}")
return {
'success': True,
'message': '压力板配置更新成功',
'config': self.get_device_config('pressure')
}
except Exception as e:
self.logger.error(f"设置压力板配置失败: {e}")
return {
'success': False,
'message': f'设置压力板配置失败: {str(e)}'
}
def set_camera_config(self, config_data: Dict[str, Any]) -> Dict[str, Any]:
"""
设置相机设备配置
Args:
config_data: 相机配置数据
{
'device_index': 1,
'width': 1280,
'height': 720,
'fps': 30
}
Returns:
Dict[str, Any]: 设置结果
"""
try:
# 验证必需参数
if 'device_index' in config_data:
self.set_config_value('CAMERA', 'device_index', str(config_data['device_index']))
if 'width' in config_data:
self.set_config_value('CAMERA', 'width', str(config_data['width']))
if 'height' in config_data:
self.set_config_value('CAMERA', 'height', str(config_data['height']))
if 'fps' in config_data:
self.set_config_value('CAMERA', 'fps', str(config_data['fps']))
if 'backend' in config_data:
self.set_config_value('CAMERA', 'backend', str(config_data['backend']))
# 保存配置
self.save_config()
self.logger.info(f"相机配置已更新: {config_data}")
return {
'success': True,
'message': '相机配置更新成功',
'config': self.get_device_config('camera')
}
except Exception as e:
self.logger.error(f"设置相机配置失败: {e}")
return {
'success': False,
'message': f'设置相机配置失败: {str(e)}'
}
def set_femtobolt_config(self, config_data: Dict[str, Any]) -> Dict[str, Any]:
"""
设置FemtoBolt设备配置
Args:
config_data: FemtoBolt配置数据
{
'color_resolution': '1080P',
'depth_mode': 'NFOV_UNBINNED',
'fps': 30,
'depth_range_min': 1200,
'depth_range_max': 1500
}
Returns:
Dict[str, Any]: 设置结果
"""
try:
# 验证必需参数
if 'algorithm_type' in config_data:
self.set_config_value('FEMTOBOLT', 'algorithm_type', config_data['algorithm_type'])
if 'color_resolution' in config_data:
self.set_config_value('FEMTOBOLT', 'color_resolution', config_data['color_resolution'])
if 'depth_mode' in config_data:
self.set_config_value('FEMTOBOLT', 'depth_mode', config_data['depth_mode'])
if 'camera_fps' in config_data:
self.set_config_value('FEMTOBOLT', 'camera_fps', str(config_data['camera_fps']))
if 'depth_range_min' in config_data:
self.set_config_value('FEMTOBOLT', 'depth_range_min', str(config_data['depth_range_min']))
if 'depth_range_max' in config_data:
self.set_config_value('FEMTOBOLT', 'depth_range_max', str(config_data['depth_range_max']))
# 保存配置
self.save_config()
self.logger.info(f"FemtoBolt配置已更新: {config_data}")
return {
'success': True,
'message': 'FemtoBolt配置更新成功',
'config': self.get_device_config('femtobolt')
}
except Exception as e:
self.logger.error(f"设置FemtoBolt配置失败: {e}")
return {
'success': False,
'message': f'设置FemtoBolt配置失败: {str(e)}'
}
def get_all_device_configs(self) -> Dict[str, Any]:
"""
@ -541,8 +404,10 @@ class ConfigManager:
return {
'imu': self.get_device_config('imu'),
'pressure': self.get_device_config('pressure'),
'camera': self.get_device_config('camera'),
'femtobolt': self.get_device_config('femtobolt')
'camera1': self.get_device_config('camera1'),
'camera2': self.get_device_config('camera2'),
'femtobolt': self.get_device_config('femtobolt'),
'remote': self.get_device_config('remote')
}
def _batch_update_device_configs(self, configs: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
@ -563,14 +428,14 @@ class ConfigManager:
if 'imu' in configs:
try:
config_data = configs['imu']
if 'device_type' in config_data:
self.set_config_value('DEVICES', 'imu_device_type', config_data['device_type'])
if 'enable' in config_data:
self.set_config_value('DEVICES', 'imu_enable', str(config_data['enable']))
if 'use_mock' in config_data:
self.set_config_value('DEVICES', 'imu_use_mock', str(config_data['use_mock']))
if 'port' in config_data:
self.set_config_value('DEVICES', 'imu_port', config_data['port'])
if 'baudrate' in config_data:
self.set_config_value('DEVICES', 'imu_baudrate', str(config_data['baudrate']))
if 'mac_address' in config_data:
self.set_config_value('DEVICES', 'imu_mac_address', config_data['mac_address'])
if 'ble_name' in config_data:
self.set_config_value('DEVICES', 'imu_ble_name', config_data['ble_name'])
results['imu'] = {
'success': True,
@ -588,8 +453,8 @@ class ConfigManager:
if 'pressure' in configs:
try:
config_data = configs['pressure']
if 'device_type' in config_data:
self.set_config_value('DEVICES', 'pressure_device_type', config_data['device_type'])
if 'enable' in config_data:
self.set_config_value('DEVICES', 'pressure_enable', str(config_data['enable']))
if 'use_mock' in config_data:
self.set_config_value('DEVICES', 'pressure_use_mock', str(config_data['use_mock']))
if 'port' in config_data:
@ -610,27 +475,29 @@ class ConfigManager:
self.logger.error(error_msg)
# 相机配置
if 'camera' in configs:
if 'camera1' in configs:
try:
config_data = configs['camera']
config_data = configs['camera1']
if 'enable' in config_data:
self.set_config_value('CAMERA1', 'enable', str(config_data['enable']))
if 'device_index' in config_data:
self.set_config_value('CAMERA', 'device_index', str(config_data['device_index']))
self.set_config_value('CAMERA1', 'device_index', str(config_data['device_index']))
if 'width' in config_data:
self.set_config_value('CAMERA', 'width', str(config_data['width']))
self.set_config_value('CAMERA1', 'width', str(config_data['width']))
if 'height' in config_data:
self.set_config_value('CAMERA', 'height', str(config_data['height']))
self.set_config_value('CAMERA1', 'height', str(config_data['height']))
if 'fps' in config_data:
self.set_config_value('CAMERA', 'fps', str(config_data['fps']))
self.set_config_value('CAMERA1', 'fps', str(config_data['fps']))
if 'buffer_size' in config_data:
self.set_config_value('CAMERA', 'buffer_size', str(config_data['buffer_size']))
self.set_config_value('CAMERA1', 'buffer_size', str(config_data['buffer_size']))
if 'fourcc' in config_data:
self.set_config_value('CAMERA', 'fourcc', config_data['fourcc'])
self.set_config_value('CAMERA1', 'fourcc', config_data['fourcc'])
if 'tx_max_width' in config_data:
self.set_config_value('CAMERA', 'tx_max_width', str(config_data['tx_max_width']))
self.set_config_value('CAMERA1', 'tx_max_width', str(config_data['tx_max_width']))
if 'backend' in config_data:
self.set_config_value('CAMERA', 'backend', str(config_data['backend']))
self.set_config_value('CAMERA1', 'backend', str(config_data['backend']))
results['camera'] = {
results['camera1'] = {
'success': True,
'message': '相机配置更新成功',
'config': config_data
@ -638,14 +505,50 @@ class ConfigManager:
self.logger.info(f"相机配置已更新: {config_data}")
except Exception as e:
error_msg = f'设置相机配置失败: {str(e)}'
results['camera'] = {'success': False, 'message': error_msg}
results['camera1'] = {'success': False, 'message': error_msg}
errors.append(f"相机: {error_msg}")
self.logger.error(error_msg)
if 'camera2' in configs:
try:
config_data = configs['camera2']
if 'enable' in config_data:
self.set_config_value('CAMERA2', 'enable', str(config_data['enable']))
if 'device_index' in config_data:
self.set_config_value('CAMERA2', 'device_index', str(config_data['device_index']))
if 'width' in config_data:
self.set_config_value('CAMERA2', 'width', str(config_data['width']))
if 'height' in config_data:
self.set_config_value('CAMERA2', 'height', str(config_data['height']))
if 'fps' in config_data:
self.set_config_value('CAMERA2', 'fps', str(config_data['fps']))
if 'buffer_size' in config_data:
self.set_config_value('CAMERA2', 'buffer_size', str(config_data['buffer_size']))
if 'fourcc' in config_data:
self.set_config_value('CAMERA2', 'fourcc', config_data['fourcc'])
if 'tx_max_width' in config_data:
self.set_config_value('CAMERA2', 'tx_max_width', str(config_data['tx_max_width']))
if 'backend' in config_data:
self.set_config_value('CAMERA2', 'backend', str(config_data['backend']))
results['camera2'] = {
'success': True,
'message': '相机配置更新成功',
'config': config_data
}
self.logger.info(f"相机配置已更新: {config_data}")
except Exception as e:
error_msg = f'设置相机配置失败: {str(e)}'
results['camera2'] = {'success': False, 'message': error_msg}
errors.append(f"相机2: {error_msg}")
self.logger.error(error_msg)
# FemtoBolt配置
if 'femtobolt' in configs:
try:
config_data = configs['femtobolt']
if 'enable' in config_data:
self.set_config_value('FEMTOBOLT', 'enable', str(config_data['enable']))
if 'algorithm_type' in config_data:
self.set_config_value('FEMTOBOLT', 'algorithm_type', config_data['algorithm_type'])
if 'color_resolution' in config_data:
@ -677,6 +580,33 @@ class ConfigManager:
errors.append(f"FemtoBolt: {error_msg}")
self.logger.error(error_msg)
# Remote配置
if 'remote' in configs:
try:
config_data = configs['remote']
if 'enable' in config_data:
self.set_config_value('REMOTE', 'enable', str(config_data['enable']))
if 'port' in config_data:
self.set_config_value('REMOTE', 'port', config_data['port'])
if 'baudrate' in config_data:
self.set_config_value('REMOTE', 'baudrate', str(config_data['baudrate']))
if 'timeout' in config_data:
self.set_config_value('REMOTE', 'timeout', str(config_data['timeout']))
if 'strict_crc' in config_data:
self.set_config_value('REMOTE', 'strict_crc', str(config_data['strict_crc']))
results['remote'] = {
'success': True,
'message': '遥控器配置更新成功',
'config': config_data
}
self.logger.info(f"遥控器配置已更新: {config_data}")
except Exception as e:
error_msg = f'设置遥控器配置失败: {str(e)}'
results['remote'] = {'success': False, 'message': error_msg}
errors.append(f"Remote: {error_msg}")
self.logger.error(error_msg)
# 一次性保存所有配置
if results: # 只有在有配置更新时才保存
self.save_config()
@ -701,10 +631,12 @@ class ConfigManager:
Args:
configs: 所有设备配置数据
{
'imu': {'device_type': 'real', 'port': 'COM7', 'baudrate': 9600},
'pressure': {'device_type': 'real', 'port': 'COM8', 'baudrate': 115200},
'camera': {'device_index': 0, 'width': 1280, 'height': 720, 'fps': 30},
'femtobolt': {'color_resolution': '1080P', 'depth_mode': 'NFOV_UNBINNED', 'fps': 15}
'imu': {'enable': True, 'use_mock': False, 'mac_address': 'FA:E8:88:06:FE:F3'},
'pressure': {'enable': True, 'device_type': 'real', 'use_mock': False, 'port': 'COM8', 'baudrate': 115200},
'camera1': {'device_index': 0, 'width': 1280, 'height': 720, 'fps': 30},
'camera2': {'device_index': 1, 'width': 1280, 'height': 720, 'fps': 30},
'femtobolt': {'color_resolution': '1080P', 'depth_mode': 'NFOV_UNBINNED', 'fps': 15},
'remote': {'enable': True, 'port': 'COM6', 'baudrate': 115200, 'timeout': 0.1, 'strict_crc': False}
}
Returns:
@ -743,4 +675,4 @@ class ConfigManager:
return {
'success': False,
'message': f'批量设置设备配置失败: {str(e)}'
}
}

View File

@ -16,6 +16,7 @@ from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.exceptions import InvalidSignature
import base64
import shutil
logger = logging.getLogger(__name__)
@ -51,96 +52,359 @@ class LicenseManager:
def __init__(self, config_manager=None):
self.config_manager = config_manager
self._machine_id = None
self._machine_id_candidates = None
self._license_cache = None
self._cache_timestamp = None
def _get_backend_base_dir(self) -> str:
return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
def _get_persistent_license_dir(self) -> str:
system = platform.system()
if system == "Windows":
base = os.environ.get("PROGRAMDATA") or os.environ.get("ALLUSERSPROFILE") or ""
if not base:
base = os.path.expanduser("~\\AppData\\Local")
else:
base = os.path.expanduser("~/.config")
return os.path.join(base, "BodyCheck", "license")
def _get_configured_license_paths(self) -> Tuple[str, str, int]:
license_path = "data/license.json"
public_key_path = "backend/license_pub.pem"
grace_days = 3
if self.config_manager:
license_path = self.config_manager.get_config_value("LICENSE", "path", license_path)
public_key_path = self.config_manager.get_config_value("LICENSE", "public_key", public_key_path)
grace_days = int(self.config_manager.get_config_value("LICENSE", "grace_days", str(grace_days)))
if not os.path.isabs(license_path):
license_path = os.path.join(self._get_backend_base_dir(), license_path)
if not os.path.isabs(public_key_path):
public_key_path = os.path.join(self._get_backend_base_dir(), public_key_path)
return license_path, public_key_path, grace_days
def _get_persistent_license_paths(self) -> Tuple[str, str]:
pdir = self._get_persistent_license_dir()
return os.path.join(pdir, "license.json"), os.path.join(pdir, "license_public_key.pem")
def _resolve_license_paths(self) -> Tuple[str, str, int]:
cfg_license_path, cfg_public_key_path, grace_days = self._get_configured_license_paths()
p_license_path, p_public_key_path = self._get_persistent_license_paths()
effective_license_path = cfg_license_path
if not os.path.exists(effective_license_path) and os.path.exists(p_license_path):
effective_license_path = p_license_path
effective_public_key_path = cfg_public_key_path
if not os.path.exists(effective_public_key_path) and os.path.exists(p_public_key_path):
effective_public_key_path = p_public_key_path
return effective_license_path, effective_public_key_path, grace_days
def _mirror_license_assets(self, license_path: Optional[str] = None, public_key_path: Optional[str] = None) -> None:
p_license_path, p_public_key_path = self._get_persistent_license_paths()
pdir = os.path.dirname(p_license_path)
try:
os.makedirs(pdir, exist_ok=True)
except Exception:
return
if license_path and os.path.exists(license_path):
try:
shutil.copyfile(license_path, p_license_path)
except Exception:
pass
if public_key_path and os.path.exists(public_key_path):
try:
shutil.copyfile(public_key_path, p_public_key_path)
except Exception:
pass
def install_license_file(self, source_license_path: str) -> Tuple[bool, str]:
try:
if not os.path.exists(source_license_path):
return False, "源授权文件不存在"
cfg_license_path, cfg_public_key_path, _ = self._get_configured_license_paths()
os.makedirs(os.path.dirname(cfg_license_path), exist_ok=True)
shutil.copyfile(source_license_path, cfg_license_path)
self._mirror_license_assets(license_path=cfg_license_path, public_key_path=cfg_public_key_path)
return True, cfg_license_path
except Exception as e:
return False, str(e)
def mirror_license_dir(self, source_dir: str) -> None:
if not source_dir or not os.path.isdir(source_dir):
return
license_candidate = os.path.join(source_dir, "license.json")
pub_candidates = [
os.path.join(source_dir, "license_public_key.pem"),
os.path.join(source_dir, "license_pub.pem"),
os.path.join(source_dir, "public_key.pem"),
]
pub_path = ""
for c in pub_candidates:
if os.path.exists(c):
pub_path = c
break
self._mirror_license_assets(license_path=license_candidate if os.path.exists(license_candidate) else None, public_key_path=pub_path or None)
def _run_powershell(self, command: str, timeout: int = 10) -> str:
try:
result = subprocess.run(
[
"powershell",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
command,
],
capture_output=True,
text=True,
timeout=timeout,
)
return (result.stdout or "").strip()
except Exception:
return ""
def _get_windows_cpu_id(self) -> str:
out = self._run_powershell(
"(Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 -ExpandProperty ProcessorId)"
)
return out.strip()
def _get_windows_baseboard_serial(self) -> str:
out = self._run_powershell(
"(Get-CimInstance -ClassName Win32_BaseBoard | Select-Object -First 1 -ExpandProperty SerialNumber)"
)
serial = out.strip()
if serial and serial != "To be filled by O.E.M.":
return serial
return ""
def _get_windows_disk_serials(self) -> list:
raw = self._run_powershell(
"$d=Get-CimInstance -ClassName Win32_DiskDrive | Select-Object SerialNumber,InterfaceType,PNPDeviceID,MediaType; $d | ConvertTo-Json -Compress"
)
if not raw:
return []
try:
data = json.loads(raw)
except Exception:
return []
disks = data if isinstance(data, list) else ([data] if isinstance(data, dict) else [])
serials = []
for d in disks:
serial = str((d.get("SerialNumber") or "")).strip()
iface = str((d.get("InterfaceType") or "")).strip().upper()
pnp = str((d.get("PNPDeviceID") or "")).strip().upper()
media = str((d.get("MediaType") or "")).strip().upper()
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
serials.append(serial)
serials = sorted(set(serials))
return serials
def _get_windows_identifiers(self) -> Dict[str, Any]:
cpu_id = self._get_windows_cpu_id()
board_serial = self._get_windows_baseboard_serial()
disk_serials = self._get_windows_disk_serials()
if not cpu_id:
try:
result = subprocess.run(
["wmic", "cpu", "get", "ProcessorId", "/value"],
capture_output=True,
text=True,
timeout=10,
)
for line in (result.stdout or "").split("\n"):
if "ProcessorId=" in line:
cpu_id = line.split("=", 1)[1].strip()
if cpu_id:
break
except Exception:
pass
if not board_serial:
try:
result = subprocess.run(
["wmic", "baseboard", "get", "SerialNumber", "/value"],
capture_output=True,
text=True,
timeout=10,
)
for line in (result.stdout or "").split("\n"):
if "SerialNumber=" in line:
board_serial = line.split("=", 1)[1].strip()
if board_serial and board_serial != "To be filled by O.E.M.":
break
board_serial = ""
except Exception:
pass
if not disk_serials:
try:
result = subprocess.run(
[
"wmic",
"path",
"Win32_DiskDrive",
"get",
"SerialNumber,InterfaceType,PNPDeviceID,MediaType",
"/value",
],
capture_output=True,
text=True,
timeout=10,
)
block = {}
serials = []
for line in (result.stdout or "").split("\n"):
line = line.strip()
if not line:
serial = (block.get("SerialNumber") or "").strip()
iface = (block.get("InterfaceType") or "").strip().upper()
pnp = (block.get("PNPDeviceID") or "").strip().upper()
media = (block.get("MediaType") or "").strip().upper()
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
serials.append(serial)
block = {}
continue
if "=" in line:
k, v = line.split("=", 1)
block[k] = v
if block:
serial = (block.get("SerialNumber") or "").strip()
iface = (block.get("InterfaceType") or "").strip().upper()
pnp = (block.get("PNPDeviceID") or "").strip().upper()
media = (block.get("MediaType") or "").strip().upper()
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
serials.append(serial)
disk_serials = sorted(set(serials))
except Exception:
pass
mac = ""
try:
import uuid
mac = ":".join(
["{:02x}".format((uuid.getnode() >> elements) & 0xFF) for elements in range(0, 2 * 6, 2)][::-1]
)
except Exception:
mac = ""
return {
"cpu_id": cpu_id.strip() if cpu_id else "",
"board_serial": board_serial.strip() if board_serial else "",
"disk_serials": disk_serials or [],
"mac": mac,
"node": platform.node(),
"processor": platform.processor(),
}
def _hash_core_info(self, core_info: list) -> str:
combined_info = "|".join(sorted(core_info))
return hashlib.sha256(combined_info.encode("utf-8")).hexdigest()[:16].upper()
def _build_machine_id_candidates(self) -> list:
system = platform.system()
info: Dict[str, Any] = {}
if system == "Windows":
info = self._get_windows_identifiers()
else:
info = {
"cpu_id": "",
"board_serial": "",
"disk_serials": [],
"mac": "",
"node": platform.node(),
"processor": platform.processor(),
}
cpu_id = info.get("cpu_id") or ""
board_serial = info.get("board_serial") or ""
disk_serials = info.get("disk_serials") or []
mac = info.get("mac") or ""
variants = []
full_core = []
if cpu_id:
full_core.append(f"CPU:{cpu_id}")
if board_serial:
full_core.append(f"BOARD:{board_serial}")
for s in disk_serials:
full_core.append(f"DISK:{s}")
if full_core:
variants.append(full_core)
no_disk = []
if cpu_id:
no_disk.append(f"CPU:{cpu_id}")
if board_serial:
no_disk.append(f"BOARD:{board_serial}")
if no_disk:
variants.append(no_disk)
if cpu_id:
variants.append([f"CPU:{cpu_id}"])
if board_serial:
variants.append([f"BOARD:{board_serial}"])
if mac:
variants.append([f"MAC:{mac}"])
if cpu_id and mac:
variants.append([f"CPU:{cpu_id}", f"MAC:{mac}"])
fallback_core = []
node = (info.get("node") or "").strip()
proc = (info.get("processor") or "").strip()
if node:
fallback_core.append(f"NODE:{node}")
if proc:
fallback_core.append(f"PROCESSOR:{proc}")
if fallback_core:
variants.append(fallback_core)
prefix = "W10-" if system == "Windows" else "FB-"
candidates = []
for core in variants:
try:
mid = f"{prefix}{self._hash_core_info(core)}"
except Exception:
continue
if mid not in candidates:
candidates.append(mid)
return candidates
def get_machine_id(self) -> str:
"""生成机器硬件指纹"""
if self._machine_id:
return self._machine_id
try:
# 收集硬件信息
hardware_info = []
# CPU信息
try:
if platform.system() == "Windows":
result = subprocess.run(['wmic', 'cpu', 'get', 'ProcessorId', '/value'],
capture_output=True, text=True, timeout=10)
for line in result.stdout.split('\n'):
if 'ProcessorId=' in line:
cpu_id = line.split('=')[1].strip()
if cpu_id:
hardware_info.append(f"CPU:{cpu_id}")
break
else:
# Linux/Mac 可以使用其他方法获取CPU信息
pass
except Exception as e:
logger.warning(f"获取CPU信息失败: {e}")
# 主板信息
try:
if platform.system() == "Windows":
result = subprocess.run(['wmic', 'baseboard', 'get', 'SerialNumber', '/value'],
capture_output=True, text=True, timeout=10)
for line in result.stdout.split('\n'):
if 'SerialNumber=' in line:
board_serial = line.split('=')[1].strip()
if board_serial and board_serial != "To be filled by O.E.M.":
hardware_info.append(f"BOARD:{board_serial}")
break
except Exception as e:
logger.warning(f"获取主板信息失败: {e}")
# 磁盘信息
try:
if platform.system() == "Windows":
result = subprocess.run(['wmic', 'diskdrive', 'get', 'SerialNumber', '/value'],
capture_output=True, text=True, timeout=10)
for line in result.stdout.split('\n'):
if 'SerialNumber=' in line:
disk_serial = line.split('=')[1].strip()
if disk_serial:
hardware_info.append(f"DISK:{disk_serial}")
break
except Exception as e:
logger.warning(f"获取磁盘信息失败: {e}")
# MAC地址
try:
import uuid
mac = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff)
for elements in range(0,2*6,2)][::-1])
hardware_info.append(f"MAC:{mac}")
except Exception as e:
logger.warning(f"获取MAC地址失败: {e}")
# 系统信息作为补充
hardware_info.append(f"OS:{platform.system()}")
hardware_info.append(f"MACHINE:{platform.machine()}")
# 如果没有获取到足够的硬件信息使用系统信息作为fallback
if len(hardware_info) < 2:
hardware_info.append(f"NODE:{platform.node()}")
hardware_info.append(f"PROCESSOR:{platform.processor()}")
# 生成指纹哈希
combined_info = "|".join(sorted(hardware_info))
machine_id = hashlib.sha256(combined_info.encode('utf-8')).hexdigest()[:16].upper()
self._machine_id = f"W10-{machine_id}"
candidates = self._build_machine_id_candidates()
if not candidates:
raise RuntimeError("无法生成机器指纹")
self._machine_id_candidates = candidates
self._machine_id = candidates[0]
logger.info(f"生成机器指纹: {self._machine_id}")
return self._machine_id
except Exception as e:
logger.error(f"生成机器指纹失败: {e}")
# 使用fallback方案
fallback_info = f"{platform.system()}-{platform.node()}-{platform.machine()}"
fallback_id = hashlib.md5(fallback_info.encode('utf-8')).hexdigest()[:12].upper()
self._machine_id = f"FB-{fallback_id}"
self._machine_id_candidates = [self._machine_id]
return self._machine_id
def get_machine_id_candidates(self) -> list:
if self._machine_id_candidates is None:
self.get_machine_id()
return list(self._machine_id_candidates or [])
def load_license(self, license_path: str) -> Optional[Dict[str, Any]]:
"""加载授权文件"""
@ -234,7 +498,13 @@ class LicenseManager:
# 检查机器绑定
license_machine_id = license_data.get('machine_id', '')
if license_machine_id != machine_id:
candidates = []
if machine_id:
candidates.append(machine_id)
for mid in self.get_machine_id_candidates():
if mid not in candidates:
candidates.append(mid)
if license_machine_id not in candidates:
return False, f"授权文件与当前机器不匹配 (当前: {machine_id}, 授权: {license_machine_id})"
# 检查有效期
@ -271,19 +541,8 @@ class LicenseManager:
# 获取配置
if not self.config_manager:
return LicenseStatus(valid=False, message="配置管理器未初始化")
license_path = self.config_manager.get_config_value('LICENSE', 'path', 'data/license.json')
public_key_path = self.config_manager.get_config_value('LICENSE', 'public_key', 'backend/license_pub.pem')
grace_days = int(self.config_manager.get_config_value('LICENSE', 'grace_days', '3'))
# 转换为绝对路径
if not os.path.isabs(license_path):
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # backend目录
license_path = os.path.join(base_dir, license_path)
if not os.path.isabs(public_key_path):
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # backend目录
public_key_path = os.path.join(base_dir, public_key_path)
license_path, public_key_path, grace_days = self._resolve_license_paths()
# 获取机器指纹
machine_id = self.get_machine_id()
@ -311,6 +570,8 @@ class LicenseManager:
self._license_cache = status
self._cache_timestamp = datetime.now().timestamp()
return status
self._mirror_license_assets(license_path=license_path, public_key_path=public_key_path)
# 检查有效性
is_valid, message = self.check_validity(license_data, machine_id, grace_days)
@ -365,14 +626,7 @@ class LicenseManager:
if not self.config_manager:
return False, "配置管理器未初始化"
# 解析公钥路径与宽限期
public_key_path = self.config_manager.get_config_value('LICENSE', 'public_key', 'backend/license_pub.pem')
grace_days = int(self.config_manager.get_config_value('LICENSE', 'grace_days', '3'))
# 转换为绝对路径相对backend目录
if not os.path.isabs(public_key_path):
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
public_key_path = os.path.join(base_dir, public_key_path)
_, public_key_path, grace_days = self._resolve_license_paths()
if not os.path.exists(license_path):
return False, f"授权文件不存在: {license_path}"
@ -401,7 +655,7 @@ class LicenseManager:
request_data = {
"product": "BodyBalanceEvaluation",
"version": "1.0.0",
"version": "1.5.0",
"machine_id": machine_id,
"platform": platform.system(),
"request_time": datetime.now(timezone.utc).isoformat(),
@ -429,4 +683,4 @@ class LicenseManager:
except Exception as e:
logger.error(f"生成激活请求失败: {e}")
raise
raise

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,64 @@
import os
from datetime import datetime, timedelta, timezone
import pytest
class DummyConfigManager:
def __init__(self, values):
self._values = values
def get_config_value(self, section, key, fallback=None):
return self._values.get((section, key), fallback)
def test_check_validity_accepts_candidate_machine_id(monkeypatch):
from devices.utils.license_manager import LicenseManager
lm = LicenseManager(config_manager=DummyConfigManager({}))
monkeypatch.setattr(lm, "get_machine_id_candidates", lambda: ["MID-PRIMARY", "MID-ALT"])
license_data = {
"product": "BodyBalanceEvaluation",
"license_id": "L1",
"license_type": "full",
"machine_id": "MID-ALT",
"issued_at": datetime.now(timezone.utc).isoformat(),
"expires_at": (datetime.now(timezone.utc) + timedelta(days=1)).isoformat().replace("+00:00", "Z"),
"signature": "x",
"features": {"export": True},
}
ok, msg = lm.check_validity(license_data, machine_id="MID-PRIMARY", grace_days=0)
assert ok is True, msg
def test_resolve_license_paths_falls_back_to_persistent(monkeypatch, tmp_path):
from devices.utils.license_manager import LicenseManager
programdata = tmp_path / "programdata"
persistent_dir = programdata / "BodyCheck" / "license"
persistent_dir.mkdir(parents=True)
(persistent_dir / "license.json").write_text('{"a":1}', encoding="utf-8")
(persistent_dir / "license_public_key.pem").write_text("PUB", encoding="utf-8")
cfg_dir = tmp_path / "cfg"
cfg_license_path = str(cfg_dir / "license.json")
cfg_pub_path = str(cfg_dir / "license_public_key.pem")
cfg = DummyConfigManager(
{
("LICENSE", "path"): cfg_license_path,
("LICENSE", "public_key"): cfg_pub_path,
("LICENSE", "grace_days"): "3",
}
)
lm = LicenseManager(config_manager=cfg)
monkeypatch.setenv("PROGRAMDATA", str(programdata))
monkeypatch.setattr("platform.system", lambda: "Windows")
license_path, pub_path, grace_days = lm._resolve_license_paths()
assert license_path == str(persistent_dir / "license.json")
assert pub_path == str(persistent_dir / "license_public_key.pem")
assert grace_days == 3

View File

@ -46,7 +46,7 @@ class Config:
# 应用配置
self.config['APP'] = {
'name': 'Body Balance Evaluation System',
'version': '1.0.0',
'version': '1.5.0',
'debug': 'false',
'log_level': 'INFO'
}
@ -218,20 +218,19 @@ class DataValidator:
name = data['name'].strip()
if len(name) < 2 or len(name) > 50:
errors.append('姓名长度应在2-50个字符之间')
data['name'] = name
data['name'] = name
# 性别验证
if data.get('gender'):
if data['gender'] not in ['male', 'female', 'other']:
errors.append('性别值无效')
# 出生日期验证
if data.get('birth_date'):
try:
birth_date = datetime.fromisoformat(data['birth_date'].replace('Z', '+00:00'))
if birth_date > datetime.now():
birth_dt = datetime.fromisoformat(data['birth_date'].replace('Z', '+00:00'))
birth = birth_dt.date()
today = datetime.now().date()
lower = datetime(1900, 1, 1).date()
if birth > today:
errors.append('出生日期不能是未来时间')
if birth_date < datetime(1900, 1, 1):
if birth < lower:
errors.append('出生日期过早')
except ValueError:
errors.append('出生日期格式无效')
@ -518,4 +517,4 @@ class ResponseFormatter:
config = Config()
# 性能监控实例
performance_monitor = PerformanceMonitor()
performance_monitor = PerformanceMonitor()

View File

@ -1,6 +1,6 @@
[APP]
name = Body Balance Evaluation System
version = 1.0.0
version = 1.5.0
debug = false
log_level = INFO
@ -35,7 +35,7 @@ chart_dpi = 300
export_format = csv
[SECURITY]
secret_key = f50c705c26a963701a4832ae3d69a091674f587a4b02da8b1c59909c0bd312fe
secret_key = 855842922ac3d1747493bcf40f0b2534387ac6304b903c901cf980e4059d5150
session_timeout = 3600
max_login_attempts = 5

View File

@ -0,0 +1,259 @@
# Web 接口调用说明
本文档基于 `backend/main.py` 中注册的路由,整理对外提供的 Web API 调用方式、参数与返回示例,便于前端或第三方系统集成。
## 总览
- 基础与健康检查
- `GET /health`
- `GET /api/health`
- 授权相关
- `GET /api/license/info`
- `POST /api/license/activation-request`
- `POST /api/license/verify`
- `POST /api/license/activate-package`
- 认证与用户
- `POST /api/auth/login`
- `POST /api/auth/register`
- `POST /api/auth/logout`
- `GET /api/auth/verify`
- `POST /api/auth/forgot-password`
- `GET /api/users`
- `POST /api/users/<user_id>/approve`
- `DELETE /api/users/<user_id>`
- 设备与配置
- `GET /api/devices/status`
- `POST /api/devices/refresh`
- `GET /api/config/devices`
- `POST /api/config/devices/all`
- `POST /api/devices/calibrate`
- `POST /api/devices/calibrate/imu`
- 患者管理
- `GET /api/patients`
- `GET|PUT|DELETE /api/patients/<patient_id>`
- 检测流程
- `POST /api/detection/start`
- `GET /api/detection/<session_id>/has_data`
- `POST /api/detection/<session_id>/start_record`
- `POST /api/detection/<session_id>/stop_record`
- `POST /api/detection/<session_id>/save-data`
- `POST /api/detection/<session_id>/save-info`
- `GET /api/detection/<session_id>/status`
- `POST /api/detection/<session_id>/stop`
- 历史与数据查询
- `GET /api/history/sessions`
- `GET /api/history/sessions/<session_id>`
- `GET /api/detection/data/details?ids=<id1,id2,...>`
- 删除操作
- `DELETE /api/detection/data/<data_id[,data_id2,...]>`
- `DELETE /api/detection/video/<video_id[,video_id2,...]>`
- `DELETE /api/detection/sessions/<session_id>`
## 统一响应约定
- 成功:`{ "success": true, ... }`
- 失败:`{ "success": false, "error": "错误信息" }`
- 时间字段统一使用 ISO 文本或 `YYYY-MM-DD HH:mm:ss` 字符串。
## 基础与授权
### GET /health | GET /api/health
- 功能:健康检查与服务存活状态。
- 示例响应:
```json
{ "status": "healthy", "timestamp": "2024-01-01T12:00:00", "version": "1.5.0" }
```
### GET /api/license/info
- 功能:获取授权状态与基础信息。
- 响应字段:`valid`, `message`, `license_type`, `license_id`, `expires_at`, `features`, `machine_id`
### POST /api/license/activation-request
- 功能:生成离线激活请求文件。
- Body`{ "company_name": "公司名", "contact_info": "联系方式" }`
- 返回:`request_file` 路径与 `content` 文本。
### POST /api/license/verify
- 功能:上传并验证授权文件。
- Form-Data`license_file`(文件)。
- 返回:授权验证结果与解析出的授权信息。
### POST /api/license/activate-package
- 功能:上传激活包进行离线激活。
- 细节见服务端实现,返回激活状态与信息。
## 认证与用户
### POST /api/auth/login
- 功能:用户登录。
- Body`{ "username": "string", "password": "string" }`
### POST /api/auth/register
- 功能:用户注册。
- Body包含用户名、密码、手机号等注册信息。
### POST /api/auth/logout
- 功能:登出当前会话。
### GET /api/auth/verify
- 功能:登录状态校验。
### POST /api/auth/forgot-password
- 功能:忘记密码,根据用户名和手机号找回。
- Body`{ "username": "string", "phone": "string" }`
### GET /api/users
- 功能:获取用户列表。
- Query 可选:分页参数。
### POST /api/users/<user_id>/approve
- 功能:批准用户,使其 `is_active = 1`
- Body 可选:`{ "approved_by": 123 }`
### DELETE /api/users/<user_id>
- 功能:删除用户。
## 患者管理
### GET /api/patients
- 功能:获取患者列表。
- Query 可选:分页筛选。
### POST /api/patients
- 功能:创建新患者。
- Body患者基本信息字段姓名、性别、出生日期等
- 必填字段:`name`(姓名)、`gender`(性别)、`birth_date`(出生日期)
- 可选字段:`phone`(电话)、`email`(邮箱)、`height`(身高)、`weight`(体重)、`nationality`(民族)、`residence`(居住地)、`occupation`(职业)、`workplace`(工作单位)、`idcode`(身份证号)、`medical_history`(病史)、`notes`(备注)
### GET|PUT|DELETE /api/patients/<patient_id>
- 功能:获取/更新/删除单个患者。
- PUT Body患者基本信息字段姓名、性别、出生日期、联系方式等
## 设备与配置
### GET /api/devices/status
- 功能:获取各设备连接与工作状态。
### POST /api/devices/refresh
- 功能:刷新设备状态(重扫/重连)。
### GET /api/config/devices
- 功能:获取当前设备配置。
### POST /api/config/devices/all
- 功能:批量设置设备配置。
- Body设备配置对象集合。
### POST /api/devices/calibrate
- 功能:触发设备标定(通用)。
### POST /api/devices/calibrate/imu
- 功能:仅触发 IMU 设备标定。
## 检测流程
### POST /api/detection/start
- 功能:创建检测会话并启动设备连接监控。
- Body`{ "patient_id": "string", "creator_id": "string" }`
- 返回:`session_id`。
### GET /api/detection/<session_id>/has_data
- 功能:会话是否存在检测数据,用于判断单次检测是否有效。
- 返回:`{ "has_data": true|false }`
### GET /api/detection/<session_id>/status
- 功能:获取会话最新状态与聚合数据(源自 `get_session_data`)。
### POST /api/detection/<session_id>/stop
- 功能:结束检测并写入总结信息(自动计算时长与结束时间)。
- Body 可选:
```json
{
"diagnosis_info": "string",
"treatment_info": "string",
"remark_info": "string"
}
```
- 说明:服务端已统一走 `update_session_endcheck` 数据库流程。
### POST /api/detection/<session_id>/start_record
- 功能:开始同步录制(屏幕/相机/设备流)。
- Body 示例:
```json
{
"patient_id": "p001",
"screen_location": [0,0,1920,1080],
"femtobolt_location": [0,0,640,480],
"camera1_location": [0,0,640,480],
"camera2_location": [0,0,640,480]
}
```
- 返回:`recording` 含 `database_updates.video_paths`;服务端会自动调用 `save_detection_video` 记录视频路径(`screen_video_path`, `femtobolt_video_path`, `camera1_video_path`, `camera2_video_path`)。
### POST /api/detection/<session_id>/stop_record
- 功能:停止同步录制。
### POST /api/detection/<session_id>/save-data
- 功能:采集检测数据与截图并持久化。
- Body检测数据载荷结构由实际采集模块决定
- 返回:`timestamp` 与是否采集成功。
### POST /api/detection/<session_id>/save-info
- 功能:保存会话信息(诊断、处理、建议、状态)。
- Body
```json
{
"diagnosis_info": "string",
"treatment_info": "string",
"remark_info": "string",
"status": "completed|running|..."
}
```
## 历史与数据查询
### GET /api/history/sessions
- 功能:分页获取检测会话历史。
- Query`page`(默认 1、`size`(默认 10、`patient_id`(可选)。
- 返回:`sessions` 与 `total`
### GET /api/history/sessions/<session_id>
- 功能:获取会话详细数据(聚合会话、检测数据、视频)。
### GET /api/detection/data/details?ids=<id1,id2,...>
- 功能:根据多个逗号分隔的 ID 批量获取检测数据详情。
- Query`ids` 逗号分隔字符串。
## 删除操作
### DELETE /api/detection/data/<data_id[,data_id2,...]>
- 功能:删除检测数据记录,支持多个 ID 逗号分隔。
- 返回:`deleted_ids` 列表。
### DELETE /api/detection/video/<video_id[,video_id2,...]>
- 功能:删除检测视频记录,支持多个 ID 逗号分隔。
- 返回:`deleted_ids` 列表。
### DELETE /api/detection/sessions/<session_id>
- 功能:删除检测会话,同时清理关联的检测数据与视频记录。
## 错误码与常见失败
- 400缺少必要参数或请求体格式错误。
- 403授权校验失败或未授权功能。
- 404会话或资源不存在。
- 500服务内部错误设备不可用、数据库失败等
## 典型调用序列(示例)
1. 创建会话:`POST /api/detection/start`
2. 开始录制:`POST /api/detection/<session_id>/start_record`
3. 采集数据:多次 `POST /api/detection/<session_id>/save-data`
4. 停止录制:`POST /api/detection/<session_id>/stop_record`
5. 保存诊断建议:`POST /api/detection/<session_id>/save-info`
6. 结束检测:`POST /api/detection/<session_id>/stop`
7. 校验有效性:`GET /api/detection/<session_id>/has_data`
8. 查询历史:`GET /api/history/sessions`
9. 查看详情:`GET /api/history/sessions/<session_id>`
> 注:具体字段与数据结构以采集模块与数据库模型为准,本文档以主干流程为纲要,建议结合实际返回进行前端适配与校验。

View File

@ -0,0 +1,70 @@
# 串口遥控器遥控界面操作说明
## 概述
- 通过串口接收遥控器报文,解析键码并通过 WebSocket 推送到前端,实现对检测页面的远程控制。
- 后端设备名为 `remote`,事件名为 `remote_control`,命名空间为 `/devices`
## 串口配置
- 配置文件位置backend/config.ini
- 读取段与键:
- [REMOTE] port缺省 COM6
- [REMOTE] baudrate缺省 115200
- [REMOTE] timeout缺省 0.1 秒
- [DEVICES] remote_enable是否启用缺省 true
- 串口参数115200 bps8 数据位1 停止位无校验8N1
## 报文格式
- 参照 Modbus RTU 协议中功能码 0x04读输入寄存器的应答帧格式
- 帧结构:`01 04 02 00 [键码] crcL crcH`
- 固定头:`01 04 02 00`
- 第 5 字节为键码KeyCode
- CRC16Modbus RTU 小端crcL, crcH计算范围为前 5 个字节
- 报文由接收器主动上传,无需主机轮询
- 除键码与 CRC 外,前 4 字节保持不变
## 键码约定
- 左:`11`
- 右:`14`
- 上:`13`
- 下:`15`
- 中:`12`
- 电源:`0E`
- 抓屏:`0F`
## 后端实现
- 代码文件:`backend/devices/remote_control_manager.py`
- 主要逻辑:
- 打开串口并启动后台线程读取数据
- 在缓冲区中查找帧头 `01 04 02 00`,截取 7 字节帧
- 计算前 5 字节 Modbus CRC16多项式 0xA001初值 0xFFFF校验通过后解析键码
- 通过 Socket.IO 向 `/devices` 命名空间推送事件 `remote_control`,载荷示例:
- `{ "code": "0F", "name": "screenshot", "timestamp": 1731234567.89 }`
## 前端对接
- 页面:`frontend/src/renderer/src/views/Detection.vue`
- 统一设备命名空间 Socket`devicesSocket = io(BACKEND_URL + '/devices', ...)`
- 事件监听与映射:
- 监听:`devicesSocket.on('remote_control', handler)`
- 根据编码触发页面方法:
- `11``startVideoClick()`(开始录像)
- `14``stopVideoClick()`(结束录像)
- `0F``saveDetectionData()`(截图)
- 页面中相关按钮:
- 截图按钮:调用 `saveDetectionData`
- 开始录像按钮:调用 `startVideoClick`
- 结束录像按钮:调用 `stopVideoClick`
## 运行与验证
- 打包后 Electron 主进程会在窗口创建前启动后端服务
- 打开检测页面,确保设备命名空间连接成功
- 使用遥控器按键,观察页面动作对应触发
## 常见问题
- 无法接收到事件:
- 检查后端串口配置是否正确(端口被占用或不存在)
- 确认遥控器接收器已连接且在串口管理器线程持续读取
- 确认前端已连接到 `/devices` 命名空间并注册了事件监听
- CRC 错误:
- 检查物理连接和电气参数
- 若报文格式与约定不一致,请提供示例报文以调整解析逻辑

View File

@ -0,0 +1,113 @@
# 前端生成 PDF 并上传后端技术方案
## 概述
- 目标:诊断报告页面在前端生成高保真 PDF上传到 Python 后端持久化,并把文件路径写入对应检测会话。
- 推荐:使用 Electron 主进程 `webContents.printToPDF` 生成 PDF忠实度高、分页与中文字体友好作为短期备选提供 `html2canvas` 截图转 PDF 的实现。
## 架构流程
- 前端渲染进程:用户在报告页面点击“生成报告”→ 发送 IPC 请求至主进程生成 PDF → 主进程返回 PDF Buffer 给渲染进程 → 渲染进程上传后端。
- Python 后端:接收 PDF 文件流,写入 `backend/static/reports/<YYYY-MM-DD>/<session_id>.pdf`,记录相对路径到 `detection_sessions` 的报告字段。
## 前端实现Electron 渲染)
### 报告页面打印版式
- 报告根容器,例如 `#report-root`
- 打印样式建议:
- `@page { size: A4; margin: 12mm }`
- 固定宽度设计为 A4 比例,非交互元素隐藏,保留背景。
- 通过添加类名(例如 `.print-mode`)切换打印样式。
### 渲染进程触发生成
```ts
// renderer: 诊断报告视图中
import { ipcRenderer } from 'electron'
async function generatePdf(sessionId: string) {
const pdfBuffer: ArrayBuffer = await ipcRenderer.invoke('generate-report-pdf', {
selector: '#report-root',
pageSize: 'A4',
printBackground: true
})
const blob = new Blob([pdfBuffer], { type: 'application/pdf' })
const form = new FormData()
form.append('file', blob, `${sessionId}.pdf`)
const res = await fetch(`${getBackendUrl()}/api/reports/${sessionId}/upload`, {
method: 'POST',
body: form
})
const json = await res.json()
if (!json.success) throw new Error(json.error || '上传失败')
}
```
### 主进程生成 PDF
```ts
// main: 注册 IPC 处理
import { ipcMain } from 'electron'
ipcMain.handle('generate-report-pdf', async (event, payload) => {
const win = event.sender.getOwnerBrowserWindow()
await win.webContents.executeJavaScript(`
(function(){
const root = document.querySelector('${payload.selector}')
if (!root) throw new Error('报告根节点缺失')
document.body.classList.add('print-mode')
return true
})()
`)
const pdf = await win.webContents.printToPDF({
pageSize: payload.pageSize || 'A4',
printBackground: payload.printBackground !== false,
marginsType: 0
})
await win.webContents.executeJavaScript(`
(function(){ document.body.classList.remove('print-mode'); return true })()
`)
return pdf
})
```
### 更新数据库记录
```py
# database.py 片段
def update_session_report_path(self, session_id: str, report_path: str):
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('UPDATE detection_sessions SET detection_report = ? WHERE id = ?', (report_path, session_id))
conn.commit()
```
## 接口设计
- 上传 PDF`POST /api/reports/<session_id>/upload`
- 请求体:`multipart/form-data`,字段 `file`
- 返回:`{ success, path }`
## 权限与安全
- 校验 `session_id` 存在且归属当前登录用户(医生)。
- 限制最大文件大小(例如 20MB拒绝超限上传。
- 文件名使用 `session_id.pdf`,避免任意文件名写盘。
## 版式与质量建议
- 中文字体:在渲染环境预装中文字体或通过 `@font-face` 引入。
- 分页:使用 `@page``page-break-before/after` 控制分页。
- 背景Electron 打印需开启 `printBackground: true`;确保 CSS 背景不被打印忽略。
## 错误与排查
- 空白 PDF确保打印前切入 `.print-mode` 并等待资源加载;检查选择器是否找到根节点。
- 字体方框:安装中文字体或嵌入字体文件。
- 资源丢失:使用相对路径或 base64`html2canvas` 时启用 `useCORS` 且服务器设置跨域头。
- 过大或截断:分页控制,或拆页生成。
## 测试用例
- 单页报告:生成并上传,后端返回路径;数据库记录更新;历史档案中可下载或预览。
- 多页报告:存在分页断点,打印后为多页;每页标题和页码正常。
- 异常:网络断开、文件超限、会话不存在;前端提示具体错误。
## 部署说明
- Electron 打包:已使用 `electron-builder`;生成 exe 后功能一致。
- 后端存储Windows 下路径统一使用 `/` 展示;静态文件由后端提供下载或前端拼接 `BACKEND_URL + '/' + path` 访问。
## 后续扩展
- 加页眉页脚(时间、患者信息、页码)。
- 报告水印与签章。
- 历史报告下载与比对视图。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

View File

@ -1,139 +0,0 @@
Metadata-Version: 2.4
Name: cryptography
Version: 46.0.3
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: English
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: POSIX
Classifier: Operating System :: POSIX :: BSD
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
Classifier: Topic :: Security :: Cryptography
Requires-Dist: cffi>=1.14 ; python_full_version == '3.8.*' and platform_python_implementation != 'PyPy'
Requires-Dist: cffi>=2.0.0 ; python_full_version >= '3.9' and platform_python_implementation != 'PyPy'
Requires-Dist: typing-extensions>=4.13.2 ; python_full_version < '3.11'
Requires-Dist: bcrypt>=3.1.5 ; extra == 'ssh'
Requires-Dist: nox[uv]>=2024.4.15 ; extra == 'nox'
Requires-Dist: cryptography-vectors==46.0.3 ; extra == 'test'
Requires-Dist: pytest>=7.4.0 ; extra == 'test'
Requires-Dist: pytest-benchmark>=4.0 ; extra == 'test'
Requires-Dist: pytest-cov>=2.10.1 ; extra == 'test'
Requires-Dist: pytest-xdist>=3.5.0 ; extra == 'test'
Requires-Dist: pretend>=0.7 ; extra == 'test'
Requires-Dist: certifi>=2024 ; extra == 'test'
Requires-Dist: pytest-randomly ; extra == 'test-randomorder'
Requires-Dist: sphinx>=5.3.0 ; extra == 'docs'
Requires-Dist: sphinx-rtd-theme>=3.0.0 ; extra == 'docs'
Requires-Dist: sphinx-inline-tabs ; extra == 'docs'
Requires-Dist: pyenchant>=3 ; extra == 'docstest'
Requires-Dist: readme-renderer>=30.0 ; extra == 'docstest'
Requires-Dist: sphinxcontrib-spelling>=7.3.1 ; extra == 'docstest'
Requires-Dist: build>=1.0.0 ; extra == 'sdist'
Requires-Dist: ruff>=0.11.11 ; extra == 'pep8test'
Requires-Dist: mypy>=1.14 ; extra == 'pep8test'
Requires-Dist: check-sdist ; extra == 'pep8test'
Requires-Dist: click>=8.0.1 ; extra == 'pep8test'
Provides-Extra: ssh
Provides-Extra: nox
Provides-Extra: test
Provides-Extra: test-randomorder
Provides-Extra: docs
Provides-Extra: docstest
Provides-Extra: sdist
Provides-Extra: pep8test
License-File: LICENSE
License-File: LICENSE.APACHE
License-File: LICENSE.BSD
Summary: cryptography is a package which provides cryptographic recipes and primitives to Python developers.
Author-email: The Python Cryptographic Authority and individual contributors <cryptography-dev@python.org>
License-Expression: Apache-2.0 OR BSD-3-Clause
Requires-Python: >=3.8, !=3.9.0, !=3.9.1
Description-Content-Type: text/x-rst; charset=UTF-8
Project-URL: homepage, https://github.com/pyca/cryptography
Project-URL: documentation, https://cryptography.io/
Project-URL: source, https://github.com/pyca/cryptography/
Project-URL: issues, https://github.com/pyca/cryptography/issues
Project-URL: changelog, https://cryptography.io/en/latest/changelog/
pyca/cryptography
=================
.. image:: https://img.shields.io/pypi/v/cryptography.svg
:target: https://pypi.org/project/cryptography/
:alt: Latest Version
.. image:: https://readthedocs.org/projects/cryptography/badge/?version=latest
:target: https://cryptography.io
:alt: Latest Docs
.. image:: https://github.com/pyca/cryptography/actions/workflows/ci.yml/badge.svg
:target: https://github.com/pyca/cryptography/actions/workflows/ci.yml?query=branch%3Amain
``cryptography`` is a package which provides cryptographic recipes and
primitives to Python developers. Our goal is for it to be your "cryptographic
standard library". It supports Python 3.8+ and PyPy3 7.3.11+.
``cryptography`` includes both high level recipes and low level interfaces to
common cryptographic algorithms such as symmetric ciphers, message digests, and
key derivation functions. For example, to encrypt something with
``cryptography``'s high level symmetric encryption recipe:
.. code-block:: pycon
>>> from cryptography.fernet import Fernet
>>> # Put this somewhere safe!
>>> key = Fernet.generate_key()
>>> f = Fernet(key)
>>> token = f.encrypt(b"A really secret message. Not for prying eyes.")
>>> token
b'...'
>>> f.decrypt(token)
b'A really secret message. Not for prying eyes.'
You can find more information in the `documentation`_.
You can install ``cryptography`` with:
.. code-block:: console
$ pip install cryptography
For full details see `the installation documentation`_.
Discussion
~~~~~~~~~~
If you run into bugs, you can file them in our `issue tracker`_.
We maintain a `cryptography-dev`_ mailing list for development discussion.
You can also join ``#pyca`` on ``irc.libera.chat`` to ask questions or get
involved.
Security
~~~~~~~~
Need to report a security issue? Please consult our `security reporting`_
documentation.
.. _`documentation`: https://cryptography.io/
.. _`the installation documentation`: https://cryptography.io/en/latest/installation/
.. _`issue tracker`: https://github.com/pyca/cryptography/issues
.. _`cryptography-dev`: https://mail.python.org/mailman/listinfo/cryptography-dev
.. _`security reporting`: https://cryptography.io/en/latest/security/

View File

@ -1,181 +0,0 @@
cryptography-46.0.3.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
cryptography-46.0.3.dist-info/METADATA,sha256=bx2LyCEmOVUC8FH5hsGEZewWPiZoIIYTq0hM9mu9r4s,5748
cryptography-46.0.3.dist-info/RECORD,,
cryptography-46.0.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
cryptography-46.0.3.dist-info/WHEEL,sha256=8hEf8NzM1FnmM77AjVt5h8nDuYkN3UqZ79LoIAHXeRE,95
cryptography-46.0.3.dist-info/licenses/LICENSE,sha256=Pgx8CRqUi4JTO6mP18u0BDLW8amsv4X1ki0vmak65rs,197
cryptography-46.0.3.dist-info/licenses/LICENSE.APACHE,sha256=qsc7MUj20dcRHbyjIJn2jSbGRMaBOuHk8F9leaomY_4,11360
cryptography-46.0.3.dist-info/licenses/LICENSE.BSD,sha256=YCxMdILeZHndLpeTzaJ15eY9dz2s0eymiSMqtwCPtPs,1532
cryptography/__about__.py,sha256=QCLxNH_Abbygdc9RQGpUmrK14Wp3Cl_SEiB2byLwyxo,445
cryptography/__init__.py,sha256=mthuUrTd4FROCpUYrTIqhjz6s6T9djAZrV7nZ1oMm2o,364
cryptography/__pycache__/__about__.cpython-313.pyc,,
cryptography/__pycache__/__init__.cpython-313.pyc,,
cryptography/__pycache__/exceptions.cpython-313.pyc,,
cryptography/__pycache__/fernet.cpython-313.pyc,,
cryptography/__pycache__/utils.cpython-313.pyc,,
cryptography/exceptions.py,sha256=835EWILc2fwxw-gyFMriciC2SqhViETB10LBSytnDIc,1087
cryptography/fernet.py,sha256=3Cvxkh0KJSbX8HbnCHu4wfCW7U0GgfUA3v_qQ8a8iWc,6963
cryptography/hazmat/__init__.py,sha256=5IwrLWrVp0AjEr_4FdWG_V057NSJGY_W4egNNsuct0g,455
cryptography/hazmat/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/__pycache__/_oid.cpython-313.pyc,,
cryptography/hazmat/_oid.py,sha256=p8ThjwJB56Ci_rAIrjyJ1f8VjgD6e39es2dh8JIUBOw,17240
cryptography/hazmat/asn1/__init__.py,sha256=hS_EWx3wVvZzfbCcNV8hzcDnyMM8H-BhIoS1TipUosk,293
cryptography/hazmat/asn1/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/asn1/__pycache__/asn1.cpython-313.pyc,,
cryptography/hazmat/asn1/asn1.py,sha256=eMEThEXa19LQjcyVofgHsW6tsZnjp3ddH7bWkkcxfLM,3860
cryptography/hazmat/backends/__init__.py,sha256=O5jvKFQdZnXhKeqJ-HtulaEL9Ni7mr1mDzZY5kHlYhI,361
cryptography/hazmat/backends/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/backends/openssl/__init__.py,sha256=p3jmJfnCag9iE5sdMrN6VvVEu55u46xaS_IjoI0SrmA,305
cryptography/hazmat/backends/openssl/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/backends/openssl/__pycache__/backend.cpython-313.pyc,,
cryptography/hazmat/backends/openssl/backend.py,sha256=tV5AxBoFJ2GfA0DMWSY-0TxQJrpQoexzI9R4Kybb--4,10215
cryptography/hazmat/bindings/__init__.py,sha256=s9oKCQ2ycFdXoERdS1imafueSkBsL9kvbyfghaauZ9Y,180
cryptography/hazmat/bindings/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/bindings/_rust.pyd,sha256=kvWLtPAadaDvTdlCXcKpbd_iX8k_2dwR6o8NBbek8IU,9245696
cryptography/hazmat/bindings/_rust/__init__.pyi,sha256=KhqLhXFPArPzzJ7DYO9Fl8FoXB_BagAd_r4Dm_Ze9Xo,1257
cryptography/hazmat/bindings/_rust/_openssl.pyi,sha256=mpNJLuYLbCVrd5i33FBTmWwL_55Dw7JPkSLlSX9Q7oI,230
cryptography/hazmat/bindings/_rust/asn1.pyi,sha256=BrGjC8J6nwuS-r3EVcdXJB8ndotfY9mbQYOfpbPG0HA,354
cryptography/hazmat/bindings/_rust/declarative_asn1.pyi,sha256=2ECFmYue1EPkHEE2Bm7aLwkjB0mSUTpr23v9MN4pri4,892
cryptography/hazmat/bindings/_rust/exceptions.pyi,sha256=exXr2xw_0pB1kk93cYbM3MohbzoUkjOms1ZMUi0uQZE,640
cryptography/hazmat/bindings/_rust/ocsp.pyi,sha256=VPVWuKHI9EMs09ZLRYAGvR0Iz0mCMmEzXAkgJHovpoM,4020
cryptography/hazmat/bindings/_rust/openssl/__init__.pyi,sha256=iOAMDyHoNwwCSZfZzuXDr64g4GpGUeDgEN-LjXqdrBM,1522
cryptography/hazmat/bindings/_rust/openssl/aead.pyi,sha256=4Nddw6-ynzIB3w2W86WvkGKTLlTDk_6F5l54RHCuy3E,2688
cryptography/hazmat/bindings/_rust/openssl/ciphers.pyi,sha256=LhPzHWSXJq4grAJXn6zSvSSdV-aYIIscHDwIPlJGGPs,1315
cryptography/hazmat/bindings/_rust/openssl/cmac.pyi,sha256=nPH0X57RYpsAkRowVpjQiHE566ThUTx7YXrsadmrmHk,564
cryptography/hazmat/bindings/_rust/openssl/dh.pyi,sha256=Z3TC-G04-THtSdAOPLM1h2G7ml5bda1ElZUcn5wpuhk,1564
cryptography/hazmat/bindings/_rust/openssl/dsa.pyi,sha256=qBtkgj2albt2qFcnZ9UDrhzoNhCVO7HTby5VSf1EXMI,1299
cryptography/hazmat/bindings/_rust/openssl/ec.pyi,sha256=zJy0pRa5n-_p2dm45PxECB_-B6SVZyNKfjxFDpPqT38,1691
cryptography/hazmat/bindings/_rust/openssl/ed25519.pyi,sha256=VXfXd5G6hUivg399R1DYdmW3eTb0EebzDTqjRC2gaRw,532
cryptography/hazmat/bindings/_rust/openssl/ed448.pyi,sha256=Yx49lqdnjsD7bxiDV1kcaMrDktug5evi5a6zerMiy2s,514
cryptography/hazmat/bindings/_rust/openssl/hashes.pyi,sha256=OWZvBx7xfo_HJl41Nc--DugVyCVPIprZ3HlOPTSWH9g,984
cryptography/hazmat/bindings/_rust/openssl/hmac.pyi,sha256=BXZn7NDjL3JAbYW0SQ8pg1iyC5DbQXVhUAiwsi8DFR8,702
cryptography/hazmat/bindings/_rust/openssl/kdf.pyi,sha256=xXfFBb9QehHfDtEaxV_65Z0YK7NquOVIChpTLkgAs_k,2029
cryptography/hazmat/bindings/_rust/openssl/keys.pyi,sha256=teIt8M6ZEMJrn4s3W0UnW0DZ-30Jd68WnSsKKG124l0,912
cryptography/hazmat/bindings/_rust/openssl/poly1305.pyi,sha256=_SW9NtQ5FDlAbdclFtWpT4lGmxKIKHpN-4j8J2BzYfQ,585
cryptography/hazmat/bindings/_rust/openssl/rsa.pyi,sha256=2OQCNSXkxgc-3uw1xiCCloIQTV6p9_kK79Yu0rhZgPc,1364
cryptography/hazmat/bindings/_rust/openssl/x25519.pyi,sha256=ewn4GpQyb7zPwE-ni7GtyQgMC0A1mLuqYsSyqv6nI_s,523
cryptography/hazmat/bindings/_rust/openssl/x448.pyi,sha256=juTZTmli8jO_5Vcufg-vHvx_tCyezmSLIh_9PU3TczI,505
cryptography/hazmat/bindings/_rust/pkcs12.pyi,sha256=vEEd5wDiZvb8ZGFaziLCaWLzAwoG_tvPUxLQw5_uOl8,1605
cryptography/hazmat/bindings/_rust/pkcs7.pyi,sha256=txGBJijqZshEcqra6byPNbnisIdlxzOSIHP2hl9arPs,1601
cryptography/hazmat/bindings/_rust/test_support.pyi,sha256=PPhld-WkO743iXFPebeG0LtgK0aTzGdjcIsay1Gm5GE,757
cryptography/hazmat/bindings/_rust/x509.pyi,sha256=n9X0IQ6ICbdIi-ExdCFZoBgeY6njm3QOVAVZwDQdnbk,9784
cryptography/hazmat/bindings/openssl/__init__.py,sha256=s9oKCQ2ycFdXoERdS1imafueSkBsL9kvbyfghaauZ9Y,180
cryptography/hazmat/bindings/openssl/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/bindings/openssl/__pycache__/_conditional.cpython-313.pyc,,
cryptography/hazmat/bindings/openssl/__pycache__/binding.cpython-313.pyc,,
cryptography/hazmat/bindings/openssl/_conditional.py,sha256=DMOpA_XN4l70zTc5_J9DpwlbQeUBRTWpfIJ4yRIn1-U,5791
cryptography/hazmat/bindings/openssl/binding.py,sha256=x8eocEmukO4cm7cHqfVmOoYY7CCXdoF1v1WhZQt9neo,4610
cryptography/hazmat/decrepit/__init__.py,sha256=wHCbWfaefa-fk6THSw9th9fJUsStJo7245wfFBqmduA,216
cryptography/hazmat/decrepit/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/decrepit/ciphers/__init__.py,sha256=wHCbWfaefa-fk6THSw9th9fJUsStJo7245wfFBqmduA,216
cryptography/hazmat/decrepit/ciphers/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/decrepit/ciphers/__pycache__/algorithms.cpython-313.pyc,,
cryptography/hazmat/decrepit/ciphers/algorithms.py,sha256=YrKgHS4MfwWaMmPBYRymRRlC0phwWp9ycICFezeJPGk,2595
cryptography/hazmat/primitives/__init__.py,sha256=s9oKCQ2ycFdXoERdS1imafueSkBsL9kvbyfghaauZ9Y,180
cryptography/hazmat/primitives/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/primitives/__pycache__/_asymmetric.cpython-313.pyc,,
cryptography/hazmat/primitives/__pycache__/_cipheralgorithm.cpython-313.pyc,,
cryptography/hazmat/primitives/__pycache__/_serialization.cpython-313.pyc,,
cryptography/hazmat/primitives/__pycache__/cmac.cpython-313.pyc,,
cryptography/hazmat/primitives/__pycache__/constant_time.cpython-313.pyc,,
cryptography/hazmat/primitives/__pycache__/hashes.cpython-313.pyc,,
cryptography/hazmat/primitives/__pycache__/hmac.cpython-313.pyc,,
cryptography/hazmat/primitives/__pycache__/keywrap.cpython-313.pyc,,
cryptography/hazmat/primitives/__pycache__/padding.cpython-313.pyc,,
cryptography/hazmat/primitives/__pycache__/poly1305.cpython-313.pyc,,
cryptography/hazmat/primitives/_asymmetric.py,sha256=RhgcouUB6HTiFDBrR1LxqkMjpUxIiNvQ1r_zJjRG6qQ,532
cryptography/hazmat/primitives/_cipheralgorithm.py,sha256=Eh3i7lwedHfi0eLSsH93PZxQKzY9I6lkK67vL4V5tOc,1522
cryptography/hazmat/primitives/_serialization.py,sha256=chgPCSF2jxI2Cr5gB-qbWXOvOfupBh4CARS0KAhv9AM,5123
cryptography/hazmat/primitives/asymmetric/__init__.py,sha256=s9oKCQ2ycFdXoERdS1imafueSkBsL9kvbyfghaauZ9Y,180
cryptography/hazmat/primitives/asymmetric/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/__pycache__/dh.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/__pycache__/dsa.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/__pycache__/ec.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/__pycache__/ed25519.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/__pycache__/ed448.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/__pycache__/padding.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/__pycache__/rsa.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/__pycache__/types.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/__pycache__/utils.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/__pycache__/x25519.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/__pycache__/x448.cpython-313.pyc,,
cryptography/hazmat/primitives/asymmetric/dh.py,sha256=0v_vEFFz5pQ1QG-FkWDyvgv7IfuVZSH5Q6LyFI5A8rg,3645
cryptography/hazmat/primitives/asymmetric/dsa.py,sha256=Ld_bbbqQFz12dObHxIkzEQzX0SWWP41RLSWkYSaKhqE,4213
cryptography/hazmat/primitives/asymmetric/ec.py,sha256=Vf5ig2PcS3PVnsb5N49Kx1uIkFBJyhg4BWXThDz5cug,12999
cryptography/hazmat/primitives/asymmetric/ed25519.py,sha256=jZW5cs472wXXV3eB0sE1b8w64gdazwwU0_MT5UOTiXs,3700
cryptography/hazmat/primitives/asymmetric/ed448.py,sha256=yAetgn2f2JYf0BO8MapGzXeThsvSMG5LmUCrxVOidAA,3729
cryptography/hazmat/primitives/asymmetric/padding.py,sha256=vQ6l6gOg9HqcbOsvHrSiJRVLdEj9L4m4HkRGYziTyFA,2854
cryptography/hazmat/primitives/asymmetric/rsa.py,sha256=ZnKOo2f34MCCOupC03Y1uR-_jiSG5IrelHEmxaME3D4,8303
cryptography/hazmat/primitives/asymmetric/types.py,sha256=LnsOJym-wmPUJ7Knu_7bCNU3kIiELCd6krOaW_JU08I,2996
cryptography/hazmat/primitives/asymmetric/utils.py,sha256=DPTs6T4F-UhwzFQTh-1fSEpQzazH2jf2xpIro3ItF4o,790
cryptography/hazmat/primitives/asymmetric/x25519.py,sha256=_4nQeZ3yJ3Lg0RpXnaqA-1yt6vbx1F-wzLcaZHwSpeE,3613
cryptography/hazmat/primitives/asymmetric/x448.py,sha256=WKBLtuVfJqiBRro654fGaQAlvsKbqbNkK7c4A_ZCdV0,3642
cryptography/hazmat/primitives/ciphers/__init__.py,sha256=eyEXmjk6_CZXaOPYDr7vAYGXr29QvzgWL2-4CSolLFs,680
cryptography/hazmat/primitives/ciphers/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/primitives/ciphers/__pycache__/aead.cpython-313.pyc,,
cryptography/hazmat/primitives/ciphers/__pycache__/algorithms.cpython-313.pyc,,
cryptography/hazmat/primitives/ciphers/__pycache__/base.cpython-313.pyc,,
cryptography/hazmat/primitives/ciphers/__pycache__/modes.cpython-313.pyc,,
cryptography/hazmat/primitives/ciphers/aead.py,sha256=Fzlyx7w8KYQakzDp1zWgJnIr62zgZrgVh1u2h4exB54,634
cryptography/hazmat/primitives/ciphers/algorithms.py,sha256=Q7ZJwcsx83Mgxv5y7r6CyJKSdsOwC-my-5A67-ma2vw,3407
cryptography/hazmat/primitives/ciphers/base.py,sha256=aBC7HHBBoixebmparVr0UlODs3VD0A7B6oz_AaRjDv8,4253
cryptography/hazmat/primitives/ciphers/modes.py,sha256=20stpwhDtbAvpH0SMf9EDHIciwmTF-JMBUOZ9bU8WiQ,8318
cryptography/hazmat/primitives/cmac.py,sha256=sz_s6H_cYnOvx-VNWdIKhRhe3Ymp8z8J0D3CBqOX3gg,338
cryptography/hazmat/primitives/constant_time.py,sha256=xdunWT0nf8OvKdcqUhhlFKayGp4_PgVJRU2W1wLSr_A,422
cryptography/hazmat/primitives/hashes.py,sha256=M8BrlKB3U6DEtHvWTV5VRjpteHv1kS3Zxm_Bsk04cr8,5184
cryptography/hazmat/primitives/hmac.py,sha256=RpB3z9z5skirCQrm7zQbtnp9pLMnAjrlTUvKqF5aDDc,423
cryptography/hazmat/primitives/kdf/__init__.py,sha256=4XibZnrYq4hh5xBjWiIXzaYW6FKx8hPbVaa_cB9zS64,750
cryptography/hazmat/primitives/kdf/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/primitives/kdf/__pycache__/argon2.cpython-313.pyc,,
cryptography/hazmat/primitives/kdf/__pycache__/concatkdf.cpython-313.pyc,,
cryptography/hazmat/primitives/kdf/__pycache__/hkdf.cpython-313.pyc,,
cryptography/hazmat/primitives/kdf/__pycache__/kbkdf.cpython-313.pyc,,
cryptography/hazmat/primitives/kdf/__pycache__/pbkdf2.cpython-313.pyc,,
cryptography/hazmat/primitives/kdf/__pycache__/scrypt.cpython-313.pyc,,
cryptography/hazmat/primitives/kdf/__pycache__/x963kdf.cpython-313.pyc,,
cryptography/hazmat/primitives/kdf/argon2.py,sha256=UFDNXG0v-rw3DqAQTB1UQAsQC2M5Ejg0k_6OCyhLKus,460
cryptography/hazmat/primitives/kdf/concatkdf.py,sha256=Ua8KoLXXnzgsrAUmHpyKymaPt8aPRP0EHEaBz7QCQ9I,3737
cryptography/hazmat/primitives/kdf/hkdf.py,sha256=M0lAEfRoc4kpp4-nwDj9yB-vNZukIOYEQrUlWsBNn9o,543
cryptography/hazmat/primitives/kdf/kbkdf.py,sha256=oZepvo4evhKkkJQWRDwaPoIbyTaFmDc5NPimxg6lfKg,9165
cryptography/hazmat/primitives/kdf/pbkdf2.py,sha256=1WIwhELR0w8ztTpTu8BrFiYWmK3hUfJq08I79TxwieE,1957
cryptography/hazmat/primitives/kdf/scrypt.py,sha256=XyWUdUUmhuI9V6TqAPOvujCSMGv1XQdg0a21IWCmO-U,590
cryptography/hazmat/primitives/kdf/x963kdf.py,sha256=zLTcF665QFvXX2f8TS7fmBZTteXpFjKahzfjjQcCJyw,1999
cryptography/hazmat/primitives/keywrap.py,sha256=XV4Pj2fqSeD-RqZVvY2cA3j5_7RwJSFygYuLfk2ujCo,5650
cryptography/hazmat/primitives/padding.py,sha256=QT-U-NvV2eQGO1wVPbDiNGNSc9keRDS-ig5cQOrLz0E,1865
cryptography/hazmat/primitives/poly1305.py,sha256=P5EPQV-RB_FJPahpg01u0Ts4S_PnAmsroxIGXbGeRRo,355
cryptography/hazmat/primitives/serialization/__init__.py,sha256=Q7uTgDlt7n3WfsMT6jYwutC6DIg_7SEeoAm1GHZ5B5E,1705
cryptography/hazmat/primitives/serialization/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/primitives/serialization/__pycache__/base.cpython-313.pyc,,
cryptography/hazmat/primitives/serialization/__pycache__/pkcs12.cpython-313.pyc,,
cryptography/hazmat/primitives/serialization/__pycache__/pkcs7.cpython-313.pyc,,
cryptography/hazmat/primitives/serialization/__pycache__/ssh.cpython-313.pyc,,
cryptography/hazmat/primitives/serialization/base.py,sha256=ikq5MJIwp_oUnjiaBco_PmQwOTYuGi-XkYUYHKy8Vo0,615
cryptography/hazmat/primitives/serialization/pkcs12.py,sha256=mS9cFNG4afzvseoc5e1MWoY2VskfL8N8Y_OFjl67luY,5104
cryptography/hazmat/primitives/serialization/pkcs7.py,sha256=5OR_Tkysxaprn4FegvJIfbep9rJ9wok6FLWvWwQ5-Mg,13943
cryptography/hazmat/primitives/serialization/ssh.py,sha256=hPV5obFznz0QhFfXFPOeQ8y6MsurA0xVMQiLnLESEs8,53700
cryptography/hazmat/primitives/twofactor/__init__.py,sha256=tmMZGB-g4IU1r7lIFqASU019zr0uPp_wEBYcwdDCKCA,258
cryptography/hazmat/primitives/twofactor/__pycache__/__init__.cpython-313.pyc,,
cryptography/hazmat/primitives/twofactor/__pycache__/hotp.cpython-313.pyc,,
cryptography/hazmat/primitives/twofactor/__pycache__/totp.cpython-313.pyc,,
cryptography/hazmat/primitives/twofactor/hotp.py,sha256=ivZo5BrcCGWLsqql4nZV0XXCjyGPi_iHfDFltGlOJwk,3256
cryptography/hazmat/primitives/twofactor/totp.py,sha256=m5LPpRL00kp4zY8gTjr55Hfz9aMlPS53kHmVkSQCmdY,1652
cryptography/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
cryptography/utils.py,sha256=bZAjFC5KVpfmF29qS_18vvpW3mKxmdiRALcusHhTTkg,4301
cryptography/x509/__init__.py,sha256=xloN0swseNx-m2WFZmCA17gOoxQWqeU82UVjEdJBePQ,8257
cryptography/x509/__pycache__/__init__.cpython-313.pyc,,
cryptography/x509/__pycache__/base.cpython-313.pyc,,
cryptography/x509/__pycache__/certificate_transparency.cpython-313.pyc,,
cryptography/x509/__pycache__/extensions.cpython-313.pyc,,
cryptography/x509/__pycache__/general_name.cpython-313.pyc,,
cryptography/x509/__pycache__/name.cpython-313.pyc,,
cryptography/x509/__pycache__/ocsp.cpython-313.pyc,,
cryptography/x509/__pycache__/oid.cpython-313.pyc,,
cryptography/x509/__pycache__/verification.cpython-313.pyc,,
cryptography/x509/base.py,sha256=OrmTw3y8B6AE_nGXQPN8x9kq-d7rDWeH13gCq6T6D6U,27997
cryptography/x509/certificate_transparency.py,sha256=JqoOIDhlwInrYMFW6IFn77WJ0viF-PB_rlZV3vs9MYc,797
cryptography/x509/extensions.py,sha256=QxYrqR6SF1qzR9ZraP8wDiIczlEVlAFuwDRVcltB6Tk,77724
cryptography/x509/general_name.py,sha256=sP_rV11Qlpsk4x3XXGJY_Mv0Q_s9dtjeLckHsjpLQoQ,7836
cryptography/x509/name.py,sha256=ty0_xf0LnHwZAdEf-d8FLO1K4hGqx_7DsD3CHwoLJiY,15101
cryptography/x509/ocsp.py,sha256=Yey6NdFV1MPjop24Mj_VenjEpg3kUaMopSWOK0AbeBs,12699
cryptography/x509/oid.py,sha256=BUzgXXGVWilkBkdKPTm9R4qElE9gAGHgdYPMZAp7PJo,931
cryptography/x509/verification.py,sha256=gR2C2c-XZQtblZhT5T5vjSKOtCb74ef2alPVmEcwFlM,958

View File

@ -1,4 +0,0 @@
Wheel-Version: 1.0
Generator: maturin (1.9.4)
Root-Is-Purelib: false
Tag: cp311-abi3-win_amd64

View File

@ -1,3 +0,0 @@
This software is made available under the terms of *either* of the licenses
found in LICENSE.APACHE or LICENSE.BSD. Contributions to cryptography are made
under the terms of *both* these licenses.

View File

@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,27 +0,0 @@
Copyright (c) Individual contributors.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of PyCA Cryptography nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -1,41 +0,0 @@
[APP]
name = Body Balance Evaluation System
version = 1.0.0
debug = false
log_level = INFO
[SERVER]
host = 0.0.0.0
port = 5000
cors_origins = *
[DATABASE]
path = backend/data/body_balance.db
backup_interval = 24
max_backups = 7
[DEVICES]
camera_index = 0
camera_width = 640
camera_height = 480
camera_fps = 30
imu_port = COM3
pressure_port = COM4
[DETECTION]
default_duration = 60
sampling_rate = 30
balance_threshold = 0.2
posture_threshold = 5.0
[DATA_PROCESSING]
filter_window = 5
outlier_threshold = 2.0
chart_dpi = 300
export_format = csv
[SECURITY]
secret_key = 579012d21afe892d663698a0875c78112bb7e73e949a0d9f591515cd7fce183b
session_timeout = 3600
max_login_attempts = 5

View File

@ -43,7 +43,7 @@ const viteProcess = spawn('npm', ['run', 'dev'], {
// 等待Vite服务器启动后启动Electron
setTimeout(() => {
console.log('\n⚡ 启动Electron应用...');
const electronProcess = spawn('npx', ['electron', mainPath], {
const electronProcess = spawn('\"d:\\\\electron-v36.4.0-win32-x64\\\\electron.exe\"', [mainPath], {
stdio: 'inherit',
shell: true,
cwd: __dirname
@ -76,4 +76,4 @@ process.on('SIGTERM', () => {
console.log('\n🛑 收到终止信号,正在关闭...');
viteProcess.kill();
process.exit(0);
});
});

View File

@ -8,8 +8,9 @@
body {
margin: 0;
padding: 0;
font-family: 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: 'Noto Sans SC';
/* background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); */
background:#191d28;
}
#app {
height: 100vh;

View File

@ -1,14 +1,172 @@
const { app, BrowserWindow } = require('electron');
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const http = require('http');
const fs = require('fs');
const url = require('url');
const { spawn } = require('child_process');
const { spawn, exec, execSync } = require('child_process');
let mainWindow;
let localServer;
let backendProcess;
let splashWindow;
// app.disableHardwareAcceleration();
app.disableDomainBlockingFor3DAPIs();
console.log('Electron version:', process.versions.electron);
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.focus();
return;
}
if (app.isReady()) {
createWindow();
}
});
}
ipcMain.handle('generate-report-pdf', async (event, payload) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) throw new Error('窗口未找到');
let prevWinBg = null;
try {
if (typeof win.getBackgroundColor === 'function') {
prevWinBg = win.getBackgroundColor();
}
if (typeof win.setBackgroundColor === 'function') {
win.setBackgroundColor('#FFFFFFFF');
}
if (win.webContents && typeof win.webContents.setBackgroundColor === 'function') {
win.webContents.setBackgroundColor('#FFFFFFFF');
}
} catch (e) {}
// 1. 准备打印环境:克隆节点到独立的打印容器,确保流式布局
await win.webContents.executeJavaScript(`
(function(){
const selector = '${(payload && payload.selector) ? payload.selector : '#report-root'}';
const root = document.querySelector(selector);
if (!root) throw new Error('报告根节点缺失');
// 创建打印容器
const printContainer = document.createElement('div');
printContainer.id = 'electron-print-container';
// 样式设置绝对定位覆盖全屏背景白z-index最高
printContainer.style.position = 'absolute';
printContainer.style.top = '0';
printContainer.style.left = '0';
printContainer.style.width = '100%';
printContainer.style.minHeight = '100vh';
printContainer.style.background = '#ffffff';
printContainer.style.zIndex = '2147483647';
printContainer.style.display = 'block';
printContainer.style.overflow = 'visible'; // 关键:允许内容溢出以触发分页
// 克隆目标节点
const cloned = root.cloneNode(true);
// 强制重置克隆节点的关键样式,确保它是流式布局
cloned.style.position = 'static'; // 必须是 static 或 relative
cloned.style.display = 'block';
cloned.style.width = '100%';
cloned.style.height = 'auto';
cloned.style.overflow = 'visible';
cloned.style.margin = '0';
cloned.style.transform = 'none'; // 移除可能影响打印的变换
// 将克隆节点加入容器
printContainer.appendChild(cloned);
// 将容器加入 body
document.body.appendChild(printContainer);
// 关键修复:打印时只保留我们的打印容器可见,其他所有元素隐藏
// 我们创建一个 style 标签来强制隐藏除了 printContainer 以外的所有 body 直接子元素
const style = document.createElement('style');
style.id = 'print-style-override';
style.innerHTML = \`
@media print {
html, body {
background: #ffffff !important;
margin: 0 !important;
padding: 0 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body > *:not(#electron-print-container) {
display: none !important;
}
#electron-print-container {
display: block !important;
background: #ffffff !important;
}
}
\`;
document.head.appendChild(style);
return true;
})()
`);
try {
const pdf = await win.webContents.printToPDF({
pageSize: (payload && payload.pageSize) ? payload.pageSize : 'A4',
landscape: !!(payload && payload.landscape),
printBackground: !(payload && payload.printBackground === false),
marginsType: 0,
displayHeaderFooter: true, // 启用页眉页脚
headerTemplate: '<div></div>', // 空页眉
footerTemplate: `
<div style="width: 100%; text-align: center; font-size: 10px; font-family: 'Microsoft YaHei', sans-serif; padding-top: 5px; padding-bottom: 5px; background: #ffffff;">
<span class="pageNumber"></span> 页 / <span class="totalPages"></span>
</div>
`
});
return pdf;
} finally {
// 3. 清理环境
await win.webContents.executeJavaScript(`
(function(){
const container = document.getElementById('electron-print-container');
if (container) container.remove();
const style = document.getElementById('print-style-override');
if (style) style.remove();
return true;
})()
`);
try {
if (prevWinBg && typeof win.setBackgroundColor === 'function') {
win.setBackgroundColor(prevWinBg);
}
if (prevWinBg && win.webContents && typeof win.webContents.setBackgroundColor === 'function') {
win.webContents.setBackgroundColor(prevWinBg);
}
} catch (e) {}
}
});
function startBackendService() {
if (backendProcess) {
console.log('Backend service already started (tracked).');
return;
}
try {
const tasklistOut = execSync('tasklist /FI "IMAGENAME eq BodyBalanceBackend.exe" /NH', { windowsHide: true }).toString();
if (tasklistOut && tasklistOut.toLowerCase().includes('bodybalancebackend.exe')) {
console.log('Backend service already running (tasklist). Skip spawning a new instance.');
return;
}
} catch (e) {
console.log('Backend process detection failed, continue to spawn:', e && e.message ? e.message : e);
}
// 在打包后的应用中使用process.resourcesPath获取resources目录
const resourcesPath = process.resourcesPath || path.join(__dirname, '../..');
const backendPath = path.join(resourcesPath, 'backend/BodyBalanceBackend/BodyBalanceBackend.exe');
@ -74,7 +232,6 @@ function stopBackendService() {
// 强制杀死所有BodyBalanceBackend.exe进程
try {
const { exec } = require('child_process');
exec('taskkill /f /im BodyBalanceBackend.exe', (error, stdout, stderr) => {
if (error) {
// 如果没有找到进程taskkill会返回错误这是正常的
@ -91,6 +248,16 @@ function stopBackendService() {
}
function createWindow() {
splashWindow = new BrowserWindow({
width: 1920,
height: 1080,
frame: false,
resizable: false,
alwaysOnTop: true,
backgroundColor: '#000000',
show: true
});
splashWindow.loadFile(path.join(__dirname, 'resources/loading.html'));
mainWindow = new BrowserWindow({
width: 1920,
height: 1080,
@ -98,6 +265,7 @@ function createWindow() {
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
// sandbox: false, // 显式关闭沙盒,避免 preload 加载问题
preload: path.join(__dirname, 'preload.js')
},
icon: path.join(__dirname, '../public/logo.png'),
@ -127,21 +295,25 @@ function createWindow() {
}, 2000);
}
// 窗口就绪后再显示,避免白屏/闪烁
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
// 监听页面加载完成事件
mainWindow.webContents.once('did-finish-load', () => {
console.log('Page loaded completely');
if (splashWindow) {
splashWindow.close();
splashWindow = null;
}
mainWindow.show();
});
// 添加加载失败的处理
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => {
console.log('Failed to load:', errorDescription, 'URL:', validatedURL);
if (splashWindow) {
splashWindow.close();
splashWindow = null;
}
mainWindow.show(); // 即使加载失败也显示窗口
});
@ -152,10 +324,19 @@ function createWindow() {
}
// 关闭后端服务
stopBackendService();
if (splashWindow) {
splashWindow.close();
splashWindow = null;
}
});
}
function startLocalServer(callback) {
if (localServer) {
console.log('Local server already started on http://localhost:3000');
callback();
return;
}
const staticPath = path.join(__dirname, '../dist/');
localServer = http.createServer((req, res) => {
@ -215,27 +396,29 @@ function startLocalServer(callback) {
// 应用事件处理
// 关闭硬件加速以规避 GPU 进程异常导致的闪烁
app.disableHardwareAcceleration();
app.whenReady().then(createWindow);
// app.disableHardwareAcceleration();
if (gotSingleInstanceLock) {
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
if (localServer) {
localServer.close();
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
if (localServer) {
localServer.close();
}
// 关闭后端服务
stopBackendService();
app.quit();
}
// 关闭后端服务
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// 应用退出前清理资源
app.on('before-quit', () => {
stopBackendService();
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// 应用退出前清理资源
app.on('before-quit', () => {
stopBackendService();
});
});
}

View File

@ -1,2 +1,11 @@
const { contextBridge } = require('electron');
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
generateReportPdf: (payload) => ipcRenderer.invoke('generate-report-pdf', payload),
getBackendUrl: () => process.env.BACKEND_URL || 'http://localhost:5000',
getSocketTransports: () => {
const allowPolling = process.env.ALLOW_POLLING === '1'
return allowPolling ? ['websocket', 'polling'] : ['websocket']
}
});

View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading</title>
<style>
html, body { height: 100%; }
body {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
background: #000000;
color: #ffffff;
font-family: "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Source Han Sans SC", sans-serif;
}
.wrap { display: flex; flex-direction: column; align-items: center; }
.spinner {
width: 64px;
height: 64px;
border-radius: 50%;
background: conic-gradient(from 0deg, #ffffff 0deg, rgba(255,255,255,0.15) 90deg, rgba(255,255,255,0.15) 360deg);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.text { font-size: 30px; opacity: 0.9; letter-spacing: 0.5px; }
</style>
</head>
<body>
<div class="wrap">
<div class="spinner"></div>
<div class="text">系统启动中...</div>
</div>
</body>
</html>

View File

@ -1,20 +1,23 @@
{
"name": "body-balance-renderer",
"version": "1.0.0",
"name": "body-balance-system",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "body-balance-renderer",
"version": "1.0.0",
"name": "body-balance-system",
"version": "1.5.0",
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"axios": "^1.5.0",
"echarts": "^5.4.3",
"element-china-area-data": "^6.1.0",
"element-plus": "^2.3.9",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.4",
"pinia": "^2.1.6",
"socket.io-client": "^4.7.2",
"three": "^0.160.0",
"vue": "^3.3.4",
"vue-echarts": "^6.6.1",
"vue-router": "^4.2.4"
@ -63,10 +66,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.28.2",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.2.tgz",
"integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==",
"dev": true,
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -1167,6 +1169,12 @@
"undici-types": "~5.26.4"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/plist": {
"version": "3.0.5",
"resolved": "https://registry.npmmirror.com/@types/plist/-/plist-3.0.5.tgz",
@ -1179,6 +1187,13 @@
"xmlbuilder": ">=11.0.1"
}
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/responselike": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz",
@ -1189,6 +1204,13 @@
"@types/node": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/verror": {
"version": "1.10.11",
"resolved": "https://registry.npmmirror.com/@types/verror/-/verror-1.10.11.tgz",
@ -1731,7 +1753,7 @@
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
@ -1982,6 +2004,26 @@
"node": ">= 0.4"
}
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
@ -2012,6 +2054,12 @@
"node": ">=8"
}
},
"node_modules/china-division": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/china-division/-/china-division-2.7.0.tgz",
"integrity": "sha512-4uUPAT+1WfqDh5jytq7omdCmHNk3j+k76zEG/2IqaGcYB90c2SwcixttcypdsZ3T/9tN1TTpBDoeZn+Yw/qBEA==",
"license": "MIT"
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/chownr/-/chownr-2.0.0.tgz",
@ -2253,6 +2301,18 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/core-js": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz",
@ -2342,7 +2402,7 @@
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
@ -2608,6 +2668,16 @@
"node": ">=8"
}
},
"node_modules/dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "9.0.2",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-9.0.2.tgz",
@ -2984,6 +3054,15 @@
"node": ">= 10.0.0"
}
},
"node_modules/element-china-area-data": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/element-china-area-data/-/element-china-area-data-6.1.0.tgz",
"integrity": "sha512-IkpcjwQv2A/2AxFiSoaISZ+oMw1rZCPUSOg5sOCwT5jKc96TaawmKZeY81xfxXsO0QbKxU5LLc6AirhG52hUmg==",
"license": "MIT",
"dependencies": {
"china-division": "^2.7.0"
}
},
"node_modules/element-plus": {
"version": "2.10.7",
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.10.7.tgz",
@ -3278,6 +3357,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz",
@ -3288,6 +3378,12 @@
"pend": "~1.2.0"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz",
@ -3893,7 +3989,7 @@
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
@ -4025,6 +4121,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz",
@ -4234,6 +4336,23 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jspdf": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/junk": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/junk/-/junk-3.1.0.tgz",
@ -4756,6 +4875,12 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-author": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/parse-author/-/parse-author-2.0.0.tgz",
@ -4863,6 +4988,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@ -5016,6 +5148,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/rcedit": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/rcedit/-/rcedit-3.1.0.tgz",
@ -5103,6 +5245,13 @@
"minimatch": "^5.1.0"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
@ -5170,6 +5319,16 @@
"node": ">= 4"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmmirror.com/roarr/-/roarr-2.15.4.tgz",
@ -5542,6 +5701,16 @@
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/stat-mode": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz",
@ -5696,6 +5865,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmmirror.com/tar/-/tar-6.2.1.tgz",
@ -5783,13 +5962,19 @@
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/three": {
"version": "0.160.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.160.1.tgz",
"integrity": "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",
@ -5931,7 +6116,7 @@
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {

View File

@ -9,7 +9,7 @@
"dev:electron": "electron .",
"build": "npm run build:renderer && npm run build:electron",
"build:renderer": "vite build",
"build:electron": "electron-builder --config ./build/electron-builder.install.json",
"build:electron": "set HTTP_PROXY=&& set HTTPS_PROXY=&&electron-builder --config ./build/electron-builder.install.json",
"pack": "electron-packager . electron-browser --platform=win32 --arch=x64 --out=dist --overwrite",
"preview": "vite preview"
},
@ -18,10 +18,13 @@
"@element-plus/icons-vue": "^2.1.0",
"axios": "^1.5.0",
"echarts": "^5.4.3",
"element-china-area-data": "^6.1.0",
"element-plus": "^2.3.9",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.4",
"pinia": "^2.1.6",
"socket.io-client": "^4.7.2",
"three": "^0.160.0",
"vue": "^3.3.4",
"vue-echarts": "^6.6.1",
"vue-router": "^4.2.4"
@ -34,5 +37,5 @@
"electron-packager": "^17.1.2",
"vite": "^4.4.9",
"wait-on": "^7.0.1"
}
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="20px" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="BGPattern" patternUnits="userSpaceOnUse" alignment="0 0" imageRepeat="None" />
<mask fill="white" id="Clip290">
<path d="M 21 3.905716464285714 C 21.27038787627078 3.245957928706087 20.735890223733122 2.702498517209308 20.064990224827696 3 L 1.195525299907394 3 C 0.5249081520192851 2.7031374543769244 -0.009213485708160033 3.246214555555484 0 3.9057164642857147 L 0 18.777907668189368 C -0.00921348570815974 19.4374095769196 0.5249081520192849 19.980486678098156 1.195525299907394 20 L 20.064990224827696 20 C 20.735607380518275 19.980486688686625 21.26972903018495 19.43740958438617 21 18.777907668189368 L 21 3.905716464285714 Z M 2.39690712331663 5.118813301910299 L 18.86958541889791 5.118813301910299 L 18.86958541889791 16.06607198089701 L 16.124935967333503 12.69479119975083 C 16.025639205909982 12.57265347420645 15.845591317420697 12.55026222871889 15.718498342050971 12.64424551038206 L 12.529158297758109 15.00226127450166 L 7.056595075330296 9.606801454734219 C 7.001168958709137 9.55195013470927 6.925781695757877 9.521105478442415 6.847145988179413 9.521105478442415 C 6.748976872404263 9.521105478442415 6.6572273760629495 9.56908313075721 6.602341250664903 9.649118786960134 L 2.39690712331663 15.751042965116277 L 2.39690712331663 5.118813301910299 Z M 11.676834668311601 9.671452941029901 L 11.679225491182653 9.671452941029901 C 11.686397920097628 8.591185500830566 12.561434247724588 7.720153745847176 13.6349077620998 7.726031146594684 C 14.707185884888577 7.730733078903654 15.573854372165034 8.60881770307309 15.571463549293984 9.689085162790699 C 15.569072746272022 10.769352602990034 14.698818044538072 11.642735314368773 13.625344510313774 11.642735314368773 C 12.547089370045551 11.639208870016613 11.67444386528964 10.757597781976747 11.676834668311601 9.671452941029901 Z M 23.00927227457588 15.588826524916941 L 23.01166309744693 15.588826524916941 C 22.513068917471912 15.581809171361453 22.11262253697901 15.182360556580665 22.11262253697901 14.69202604424181 C 22.11262253697901 14.687685151925008 22.112654585514925 14.683344316806666 22 14.6790038820598 L 22 2 L 3.442886333467469 2 C 2.938287533708241 1.8228303246589928 2.5338485057905884 1.4161889726150498 2.5391603100296973 0.9199936054817273 C 2.5391603100296973 0.416887551079734 2.94440254372579 0.010170962624584555 3.442886333467469 0 L 23.01166309744693 0 C 23.51400873914463 0.018545126845467025 23.914873644129443 0.42481727570425176 24 0.9188181175249168 L 24 14.68017937001661 C 23.914204343625748 15.174824428285286 23.51227526361774 15.581091721295216 23.00927227457588 15.588826524916941 Z " fill-rule="evenodd" />
</mask>
</defs>
<g transform="matrix(1 0 0 1 -876 -78 )">
<path d="M 21 3.905716464285714 C 21.27038787627078 3.245957928706087 20.735890223733122 2.702498517209308 20.064990224827696 3 L 1.195525299907394 3 C 0.5249081520192851 2.7031374543769244 -0.009213485708160033 3.246214555555484 0 3.9057164642857147 L 0 18.777907668189368 C -0.00921348570815974 19.4374095769196 0.5249081520192849 19.980486678098156 1.195525299907394 20 L 20.064990224827696 20 C 20.735607380518275 19.980486688686625 21.26972903018495 19.43740958438617 21 18.777907668189368 L 21 3.905716464285714 Z M 2.39690712331663 5.118813301910299 L 18.86958541889791 5.118813301910299 L 18.86958541889791 16.06607198089701 L 16.124935967333503 12.69479119975083 C 16.025639205909982 12.57265347420645 15.845591317420697 12.55026222871889 15.718498342050971 12.64424551038206 L 12.529158297758109 15.00226127450166 L 7.056595075330296 9.606801454734219 C 7.001168958709137 9.55195013470927 6.925781695757877 9.521105478442415 6.847145988179413 9.521105478442415 C 6.748976872404263 9.521105478442415 6.6572273760629495 9.56908313075721 6.602341250664903 9.649118786960134 L 2.39690712331663 15.751042965116277 L 2.39690712331663 5.118813301910299 Z M 11.676834668311601 9.671452941029901 L 11.679225491182653 9.671452941029901 C 11.686397920097628 8.591185500830566 12.561434247724588 7.720153745847176 13.6349077620998 7.726031146594684 C 14.707185884888577 7.730733078903654 15.573854372165034 8.60881770307309 15.571463549293984 9.689085162790699 C 15.569072746272022 10.769352602990034 14.698818044538072 11.642735314368773 13.625344510313774 11.642735314368773 C 12.547089370045551 11.639208870016613 11.67444386528964 10.757597781976747 11.676834668311601 9.671452941029901 Z M 23.00927227457588 15.588826524916941 L 23.01166309744693 15.588826524916941 C 22.513068917471912 15.581809171361453 22.11262253697901 15.182360556580665 22.11262253697901 14.69202604424181 C 22.11262253697901 14.687685151925008 22.112654585514925 14.683344316806666 22 14.6790038820598 L 22 2 L 3.442886333467469 2 C 2.938287533708241 1.8228303246589928 2.5338485057905884 1.4161889726150498 2.5391603100296973 0.9199936054817273 C 2.5391603100296973 0.416887551079734 2.94440254372579 0.010170962624584555 3.442886333467469 0 L 23.01166309744693 0 C 23.51400873914463 0.018545126845467025 23.914873644129443 0.42481727570425176 24 0.9188181175249168 L 24 14.68017937001661 C 23.914204343625748 15.174824428285286 23.51227526361774 15.581091721295216 23.00927227457588 15.588826524916941 Z " fill-rule="nonzero" fill="rgba(255, 255, 255, 1)" stroke="none" transform="matrix(1 0 0 1 876 78 )" class="fill" />
<path d="M 21 3.905716464285714 C 21.27038787627078 3.245957928706087 20.735890223733122 2.702498517209308 20.064990224827696 3 L 1.195525299907394 3 C 0.5249081520192851 2.7031374543769244 -0.009213485708160033 3.246214555555484 0 3.9057164642857147 L 0 18.777907668189368 C -0.00921348570815974 19.4374095769196 0.5249081520192849 19.980486678098156 1.195525299907394 20 L 20.064990224827696 20 C 20.735607380518275 19.980486688686625 21.26972903018495 19.43740958438617 21 18.777907668189368 L 21 3.905716464285714 Z " stroke-width="0" stroke-dasharray="0" stroke="rgba(255, 255, 255, 0)" fill="none" transform="matrix(1 0 0 1 876 78 )" class="stroke" mask="url(#Clip290)" />
<path d="M 2.39690712331663 5.118813301910299 L 18.86958541889791 5.118813301910299 L 18.86958541889791 16.06607198089701 L 16.124935967333503 12.69479119975083 C 16.025639205909982 12.57265347420645 15.845591317420697 12.55026222871889 15.718498342050971 12.64424551038206 L 12.529158297758109 15.00226127450166 L 7.056595075330296 9.606801454734219 C 7.001168958709137 9.55195013470927 6.925781695757877 9.521105478442415 6.847145988179413 9.521105478442415 C 6.748976872404263 9.521105478442415 6.6572273760629495 9.56908313075721 6.602341250664903 9.649118786960134 L 2.39690712331663 15.751042965116277 L 2.39690712331663 5.118813301910299 Z " stroke-width="0" stroke-dasharray="0" stroke="rgba(255, 255, 255, 0)" fill="none" transform="matrix(1 0 0 1 876 78 )" class="stroke" mask="url(#Clip290)" />
<path d="M 11.676834668311601 9.671452941029901 L 11.679225491182653 9.671452941029901 C 11.686397920097628 8.591185500830566 12.561434247724588 7.720153745847176 13.6349077620998 7.726031146594684 C 14.707185884888577 7.730733078903654 15.573854372165034 8.60881770307309 15.571463549293984 9.689085162790699 C 15.569072746272022 10.769352602990034 14.698818044538072 11.642735314368773 13.625344510313774 11.642735314368773 C 12.547089370045551 11.639208870016613 11.67444386528964 10.757597781976747 11.676834668311601 9.671452941029901 Z " stroke-width="0" stroke-dasharray="0" stroke="rgba(255, 255, 255, 0)" fill="none" transform="matrix(1 0 0 1 876 78 )" class="stroke" mask="url(#Clip290)" />
<path d="M 23.00927227457588 15.588826524916941 L 23.01166309744693 15.588826524916941 C 22.513068917471912 15.581809171361453 22.11262253697901 15.182360556580665 22.11262253697901 14.69202604424181 C 22.11262253697901 14.687685151925008 22.112654585514925 14.683344316806666 22 14.6790038820598 L 22 2 L 3.442886333467469 2 C 2.938287533708241 1.8228303246589928 2.5338485057905884 1.4161889726150498 2.5391603100296973 0.9199936054817273 C 2.5391603100296973 0.416887551079734 2.94440254372579 0.010170962624584555 3.442886333467469 0 L 23.01166309744693 0 C 23.51400873914463 0.018545126845467025 23.914873644129443 0.42481727570425176 24 0.9188181175249168 L 24 14.68017937001661 C 23.914204343625748 15.174824428285286 23.51227526361774 15.581091721295216 23.00927227457588 15.588826524916941 Z " stroke-width="0" stroke-dasharray="0" stroke="rgba(255, 255, 255, 0)" fill="none" transform="matrix(1 0 0 1 876 78 )" class="stroke" mask="url(#Clip290)" />
</g>
</svg>

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

Some files were not shown because too many files have changed in this diff Show More