Migrate updater LAN and devkit features from old repo

This commit is contained in:
lenn
2026-04-27 16:37:40 +08:00
parent b33c952eb6
commit 26533f6916
29 changed files with 5207 additions and 55 deletions

Binary file not shown.

24
devkit/build_server.bat Normal file
View File

@@ -0,0 +1,24 @@
@echo off
REM ── JE-Skin DevKit: 打包 Python gRPC server 为 exe ──
REM 前提: pip install pyinstaller grpcio grpcio-tools openpyxl
echo [1/3] Generating gRPC stubs...
python -m grpc_tools.protoc ^
-I../src-tauri/proto ^
--python_out=. ^
--grpc_python_out=. ^
../src-tauri/proto/sensor_stream.proto
echo [2/3] Building exe with PyInstaller...
pyinstaller ^
--onefile ^
--name je-skin-devkit-server ^
--add-data "sensor_stream_pb2*.py;." ^
--hidden-import grpc ^
--hidden-import openpyxl ^
--noconfirm ^
sensor_server.py
echo [3/3] Done!
echo Output: dist/je-skin-devkit-server.exe
pause

View File

@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['sensor_server.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=['grpc', 'openpyxl'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='je-skin-devkit-server',
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,
)

323
devkit/sensor_server.py Normal file
View File

@@ -0,0 +1,323 @@
"""
JE-Skin DevKit — Python gRPC Sensor Server
提供两个服务:
1. SensorPush (streaming) — 接收实时传感器帧
2. ExportProcessor (unary) — 处理导出的 CSV 文件梯度过滤、xlsx 转换
安装依赖:
pip install grpcio grpcio-tools openpyxl
生成 gRPC 代码:
python -m grpc_tools.protoc -I../src-tauri/proto --python_out=. --grpc_python_out=. ../src-tauri/proto/sensor_stream.proto
启动:
python sensor_server.py [--port 50051]
"""
from __future__ import annotations
import argparse
import csv
import os
import signal
import statistics
import sys
import time
from concurrent import futures
from pathlib import Path
import grpc
import sensor_stream_pb2
import sensor_stream_pb2_grpc
# ── 梯度过滤逻辑(来自用户的 main.py ─────────────────────────
def load_rows(path: Path) -> list[list[str]]:
with path.open("r", encoding="utf-8-sig", newline="") as f:
return [row for row in csv.reader(f) if row]
def row_sum(row: list[str]) -> float:
return sum(float(v) for v in row[1:] if v.strip())
def find_threshold(sum_values: list[float]) -> float:
if len(sum_values) < 2:
raise ValueError("At least two rows are required.")
sorted_v = sorted(sum_values)
idx = max(
range(len(sorted_v) - 1),
key=lambda i: sorted_v[i + 1] - sorted_v[i],
)
return (sorted_v[idx] + sorted_v[idx + 1]) / 2.0
def extract_press_groups(
rows: list[list[str]], sum_values: list[float], threshold: float
) -> tuple[list[list[str]], list[float]]:
filtered: list[list[str]] = []
group_means: list[float] = []
current_group: list[float] = []
for row, total in zip(rows, sum_values):
if total >= threshold:
filtered.append(row)
current_group.append(total)
continue
if current_group:
group_means.append(statistics.fmean(current_group))
current_group = []
if current_group:
group_means.append(statistics.fmean(current_group))
return filtered, group_means
def write_csv(path: Path, rows: list[list[str]]) -> Path:
out = path.with_name(f"{path.stem}_filtered.csv")
with out.open("w", encoding="utf-8-sig", newline="") as f:
csv.writer(f).writerows(rows)
return out
def write_xlsx(path: Path, rows: list[list[str]], stats: dict) -> Path:
"""将过滤后的数据和统计信息写入 xlsx"""
try:
import openpyxl
except ImportError:
raise RuntimeError("openpyxl is required for xlsx output. Install it with: pip install openpyxl")
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
wb = openpyxl.Workbook()
# Sheet 1: 过滤后的数据
ws_data = wb.active
ws_data.title = "Filtered Data"
for row in rows:
ws_data.append([float(c) if c.strip().replace(".", "").replace("-", "").isdigit() else c for c in row])
# Sheet 2: 统计信息
ws_stats = wb.create_sheet("Statistics")
header_font = Font(bold=True, size=11)
header_fill = PatternFill(start_color="E0E0E0", end_color="E0E0E0", fill_type="solid")
ws_stats.append(["Parameter", "Value"])
ws_stats["A1"].font = header_font
ws_stats["A1"].fill = header_fill
ws_stats["B1"].font = header_font
ws_stats["B1"].fill = header_fill
stats_rows = [
("Source File", stats.get("source_file", "")),
("Total Rows", stats.get("rows_total", 0)),
("Filtered Rows", stats.get("rows_kept", 0)),
("Groups Used", stats.get("groups_used", 0)),
("Mean Value", f"{stats.get('mean_value', 0):.3f}"),
("Threshold", f"{stats.get('threshold', 0):.3f}"),
("Process Time", stats.get("process_time", "")),
]
for label, value in stats_rows:
ws_stats.append([label, value])
ws_stats.column_dimensions["A"].width = 18
ws_stats.column_dimensions["B"].width = 30
out = path.with_name(f"{path.stem}_filtered.xlsx")
wb.save(str(out))
return out
def process_csv(csv_path: str, save_as_xlsx: bool) -> dict:
"""执行梯度过滤,返回结果统计"""
path = Path(csv_path)
if not path.is_file():
raise FileNotFoundError(f"CSV file not found: {csv_path}")
rows = load_rows(path)
if not rows:
raise ValueError("CSV file is empty.")
sum_values = [row_sum(r) for r in rows]
threshold = find_threshold(sum_values)
filtered_rows, group_means = extract_press_groups(rows, sum_values, threshold)
if not filtered_rows:
raise ValueError("No large press-down data was found.")
overall_mean = statistics.fmean(group_means)
stats = {
"source_file": path.name,
"rows_total": len(rows),
"rows_kept": len(filtered_rows),
"groups_used": len(group_means),
"mean_value": overall_mean,
"threshold": threshold,
"process_time": time.strftime("%Y-%m-%d %H:%M:%S"),
}
if save_as_xlsx:
output_path = write_xlsx(path, filtered_rows, stats)
# 删除源 CSV
try:
path.unlink()
except OSError:
pass
else:
output_path = write_csv(path, filtered_rows)
# 用过滤后的文件替换源文件
try:
path.unlink()
output_path.rename(path)
output_path = path
except OSError:
pass
# 追加一行到汇总 xlsx
_append_analysis_log(csv_path, stats)
return {
"ok": True,
"output_path": str(output_path),
"groups_used": len(group_means),
"mean_value": overall_mean,
"threshold": threshold,
"rows_total": len(rows),
"rows_kept": len(filtered_rows),
"message": "OK",
}
def _append_analysis_log(source_csv: str, stats: dict):
"""将处理结果追加到 devkit_analysis_results.xlsx"""
try:
import openpyxl
except ImportError:
return # openpyxl 不可用时跳过
log_path = Path(source_csv).parent / "devkit_analysis_results.xlsx"
if log_path.exists():
wb = openpyxl.load_workbook(str(log_path))
ws = wb.active
else:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Analysis Log"
ws.append(["Time", "Source File", "Total Rows", "Kept Rows",
"Groups", "Mean Value", "Threshold", "Output File"])
ws.append([
stats.get("process_time", ""),
stats.get("source_file", ""),
stats.get("rows_total", 0),
stats.get("rows_kept", 0),
stats.get("groups_used", 0),
round(stats.get("mean_value", 0), 3),
round(stats.get("threshold", 0), 3),
f"{Path(stats.get('source_file', '')).stem}_filtered",
])
wb.save(str(log_path))
# ── gRPC 服务实现 ────────────────────────────────────────────────
class SensorPushServicer(sensor_stream_pb2_grpc.SensorPushServicer):
"""接收实时传感器帧streaming"""
def __init__(self):
self.frame_count = 0
self.last_report_time = time.time()
def Upload(self, request_iterator, context):
print("[SensorPush] Client connected, waiting for frames...")
for frame in request_iterator:
self.frame_count += 1
if self.frame_count % 100 == 0:
now = time.time()
elapsed = now - self.last_report_time
fps = 100 / elapsed if elapsed > 0 else 0
self.last_report_time = now
print(
f"[SensorPush] Frame #{frame.seq} | "
f"{frame.rows}x{frame.cols} | "
f"force={frame.resultant_force:.1f} | "
f"total={self.frame_count} | ~{fps:.1f} fps"
)
print(f"[SensorPush] Stream ended. Total: {self.frame_count}")
return sensor_stream_pb2.UploadResponse(
ok=True,
frames_received=self.frame_count,
message=f"Processed {self.frame_count} frames",
)
class ExportProcessorServicer(sensor_stream_pb2_grpc.ExportProcessorServicer):
"""处理导出的 CSV 文件unary"""
def ProcessFile(self, request, context):
csv_path = request.csv_path
save_as_xlsx = request.save_as_xlsx
print(f"[ExportProcessor] Processing: {csv_path} (xlsx={save_as_xlsx})")
try:
result = process_csv(csv_path, save_as_xlsx)
return sensor_stream_pb2.ProcessResponse(
ok=result["ok"],
output_path=result["output_path"],
groups_used=result["groups_used"],
mean_value=result["mean_value"],
threshold=result["threshold"],
rows_total=result["rows_total"],
rows_kept=result["rows_kept"],
message=result["message"],
)
except Exception as e:
print(f"[ExportProcessor] Error: {e}")
return sensor_stream_pb2.ProcessResponse(
ok=False,
output_path="",
message=str(e),
)
# ── 启动 ────────────────────────────────────────────────────────
def serve(port: int):
server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
sensor_stream_pb2_grpc.add_SensorPushServicer_to_server(SensorPushServicer(), server)
sensor_stream_pb2_grpc.add_ExportProcessorServicer_to_server(ExportProcessorServicer(), server)
listen_addr = f"0.0.0.0:{port}"
server.add_insecure_port(listen_addr)
server.start()
print(f"[DevKit Server] gRPC listening on {listen_addr}")
print(f"[DevKit Server] Services: SensorPush (streaming), ExportProcessor (unary)")
def shutdown(signum, frame):
print("\n[DevKit Server] Shutting down...")
server.stop(grace=5)
sys.exit(0)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
server.wait_for_termination()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="JE-Skin DevKit gRPC Server")
parser.add_argument("--port", type=int, default=50051, help="gRPC listen port (default: 50051)")
args = parser.parse_args()
serve(args.port)

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: sensor_stream.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
'sensor_stream.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13sensor_stream.proto\x12\rsensor_stream\"\x85\x01\n\x0bSensorFrame\x12\x0b\n\x03seq\x18\x01 \x01(\x04\x12\x14\n\x0ctimestamp_ms\x18\x02 \x01(\x04\x12\x0c\n\x04rows\x18\x03 \x01(\r\x12\x0c\n\x04\x63ols\x18\x04 \x01(\r\x12\x0e\n\x06matrix\x18\x05 \x03(\r\x12\x17\n\x0fresultant_force\x18\x06 \x01(\x01\x12\x0e\n\x06\x64ts_ms\x18\x07 \x01(\r\"F\n\x0eUploadResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x17\n\x0f\x66rames_received\x18\x02 \x01(\x04\x12\x0f\n\x07message\x18\x03 \x01(\t\"8\n\x0eProcessRequest\x12\x10\n\x08\x63sv_path\x18\x01 \x01(\t\x12\x14\n\x0csave_as_xlsx\x18\x02 \x01(\x08\"\xa6\x01\n\x0fProcessResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x13\n\x0boutput_path\x18\x02 \x01(\t\x12\x13\n\x0bgroups_used\x18\x03 \x01(\r\x12\x12\n\nmean_value\x18\x04 \x01(\x01\x12\x11\n\tthreshold\x18\x05 \x01(\x01\x12\x12\n\nrows_total\x18\x06 \x01(\r\x12\x11\n\trows_kept\x18\x07 \x01(\r\x12\x0f\n\x07message\x18\x08 \x01(\t2S\n\nSensorPush\x12\x45\n\x06Upload\x12\x1a.sensor_stream.SensorFrame\x1a\x1d.sensor_stream.UploadResponse(\x01\x32_\n\x0f\x45xportProcessor\x12L\n\x0bProcessFile\x12\x1d.sensor_stream.ProcessRequest\x1a\x1e.sensor_stream.ProcessResponseb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'sensor_stream_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_SENSORFRAME']._serialized_start=39
_globals['_SENSORFRAME']._serialized_end=172
_globals['_UPLOADRESPONSE']._serialized_start=174
_globals['_UPLOADRESPONSE']._serialized_end=244
_globals['_PROCESSREQUEST']._serialized_start=246
_globals['_PROCESSREQUEST']._serialized_end=302
_globals['_PROCESSRESPONSE']._serialized_start=305
_globals['_PROCESSRESPONSE']._serialized_end=471
_globals['_SENSORPUSH']._serialized_start=473
_globals['_SENSORPUSH']._serialized_end=556
_globals['_EXPORTPROCESSOR']._serialized_start=558
_globals['_EXPORTPROCESSOR']._serialized_end=653
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,175 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
import sensor_stream_pb2 as sensor__stream__pb2
GRPC_GENERATED_VERSION = '1.80.0'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in sensor_stream_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class SensorPushStub(object):
"""传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.Upload = channel.stream_unary(
'/sensor_stream.SensorPush/Upload',
request_serializer=sensor__stream__pb2.SensorFrame.SerializeToString,
response_deserializer=sensor__stream__pb2.UploadResponse.FromString,
_registered_method=True)
class SensorPushServicer(object):
"""传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧
"""
def Upload(self, request_iterator, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_SensorPushServicer_to_server(servicer, server):
rpc_method_handlers = {
'Upload': grpc.stream_unary_rpc_method_handler(
servicer.Upload,
request_deserializer=sensor__stream__pb2.SensorFrame.FromString,
response_serializer=sensor__stream__pb2.UploadResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'sensor_stream.SensorPush', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('sensor_stream.SensorPush', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class SensorPush(object):
"""传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧
"""
@staticmethod
def Upload(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_unary(
request_iterator,
target,
'/sensor_stream.SensorPush/Upload',
sensor__stream__pb2.SensorFrame.SerializeToString,
sensor__stream__pb2.UploadResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
class ExportProcessorStub(object):
"""导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.ProcessFile = channel.unary_unary(
'/sensor_stream.ExportProcessor/ProcessFile',
request_serializer=sensor__stream__pb2.ProcessRequest.SerializeToString,
response_deserializer=sensor__stream__pb2.ProcessResponse.FromString,
_registered_method=True)
class ExportProcessorServicer(object):
"""导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤
"""
def ProcessFile(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_ExportProcessorServicer_to_server(servicer, server):
rpc_method_handlers = {
'ProcessFile': grpc.unary_unary_rpc_method_handler(
servicer.ProcessFile,
request_deserializer=sensor__stream__pb2.ProcessRequest.FromString,
response_serializer=sensor__stream__pb2.ProcessResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'sensor_stream.ExportProcessor', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('sensor_stream.ExportProcessor', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class ExportProcessor(object):
"""导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤
"""
@staticmethod
def ProcessFile(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/sensor_stream.ExportProcessor/ProcessFile',
sensor__stream__pb2.ProcessRequest.SerializeToString,
sensor__stream__pb2.ProcessResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)

20
package-lock.json generated
View File

@@ -11,6 +11,8 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"three": "^0.183.2" "three": "^0.183.2"
}, },
"devDependencies": { "devDependencies": {
@@ -1280,6 +1282,24 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tauri-apps/plugin-process": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tweenjs/tween.js": { "node_modules/@tweenjs/tween.js": {
"version": "23.1.3", "version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",

View File

@@ -9,12 +9,16 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri" "tauri": "tauri",
"tauri:devkit": "tauri dev -- --features devkit",
"tauri:devkit:build": "tauri build -- --features devkit"
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"three": "^0.183.2" "three": "^0.183.2"
}, },
"devDependencies": { "devDependencies": {

1041
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,14 +14,26 @@ edition = "2021"
name = "tauri_demo_lib" name = "tauri_demo_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[features]
default = []
devkit = ["dep:tonic", "dep:prost", "dep:prost-types", "dep:async-stream", "dep:dirs"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
tonic-build = { version = "0.12" }
protoc-bin-vendored = "3"
[dependencies] [dependencies]
tauri = { version = "2", features = ["tray-icon"] } tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
anyhow = "1.0.102" anyhow = "1.0.102"
tonic = { version = "0.12", optional = true }
prost = { version = "0.13", optional = true }
prost-types = { version = "0.13", optional = true }
async-stream = { version = "0.3", optional = true }
dirs = { version = "6", optional = true }
tokio-serial = { version = "5.4.5" } tokio-serial = { version = "5.4.5" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
async-trait = "0.1.89" async-trait = "0.1.89"
@@ -33,3 +45,12 @@ humantime = "2.3.0"
csv = "1.4.0" csv = "1.4.0"
chrono = "0.4.44" chrono = "0.4.44"
crc = "3.4.0" crc = "3.4.0"
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors"] }
futures-util = "0.3"
uuid = { version = "1", features = ["v4", "serde"] }
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"

View File

@@ -1,3 +1,11 @@
fn main() { fn main() {
if std::env::var("CARGO_FEATURE_DEVKIT").is_ok() {
let protoc = protoc_bin_vendored::protoc_bin_path()
.unwrap_or_else(|error| panic!("Failed to resolve bundled protoc: {error}"));
std::env::set_var("PROTOC", protoc);
tonic_build::compile_protos("proto/sensor_stream.proto")
.unwrap_or_else(|error| panic!("Failed to compile devkit proto: {error}"));
}
tauri_build::build() tauri_build::build()
} }

View File

@@ -10,6 +10,8 @@
"core:window:allow-inner-size", "core:window:allow-inner-size",
"core:window:allow-set-size", "core:window:allow-set-size",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"opener:default" "opener:default",
"process:default",
"updater:default"
] ]
} }

View File

@@ -0,0 +1,49 @@
syntax = "proto3";
package sensor_stream;
// 传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧
service SensorPush {
rpc Upload (stream SensorFrame) returns (UploadResponse);
}
// 导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤
service ExportProcessor {
rpc ProcessFile (ProcessRequest) returns (ProcessResponse);
}
// 一帧传感器数据
message SensorFrame {
uint64 seq = 1;
uint64 timestamp_ms = 2;
uint32 rows = 3;
uint32 cols = 4;
repeated uint32 matrix = 5;
double resultant_force = 6;
uint32 dts_ms = 7;
}
// 上传确认响应
message UploadResponse {
bool ok = 1;
uint64 frames_received = 2;
string message = 3;
}
// 导出处理请求
message ProcessRequest {
string csv_path = 1; // 导出的 CSV 文件路径
bool save_as_xlsx = 2; // 是否以 xlsx 保存(删除源 CSV
}
// 导出处理响应
message ProcessResponse {
bool ok = 1;
string output_path = 2; // 输出文件路径
uint32 groups_used = 3; // 分组数
double mean_value = 4; // 均值
double threshold = 5; // 梯度阈值
uint32 rows_total = 6; // 原始行数
uint32 rows_kept = 7; // 保留行数
string message = 8;
}

Binary file not shown.

View File

@@ -0,0 +1,47 @@
//! DevKit Tauri 命令
//!
//! 仅在 `devkit` feature 启用时编译。
use tauri::State;
use crate::devkit::{DevKitConfig, DevKitState, DevKitStatusSnapshot, ExportProcessResult};
#[tauri::command]
pub fn devkit_status(state: State<'_, DevKitState>) -> DevKitStatusSnapshot {
state.status()
}
#[tauri::command]
pub async fn devkit_start(state: State<'_, DevKitState>, port: Option<u16>) -> Result<DevKitStatusSnapshot, String> {
let target_port = port.unwrap_or(50051);
state.start(target_port).await?;
Ok(state.status())
}
#[tauri::command]
pub async fn devkit_stop(state: State<'_, DevKitState>) -> Result<DevKitStatusSnapshot, String> {
state.stop().await?;
Ok(state.status())
}
#[tauri::command]
pub fn devkit_get_config(state: State<'_, DevKitState>) -> DevKitConfig {
state.get_config()
}
#[tauri::command]
pub fn devkit_set_config(state: State<'_, DevKitState>, config: DevKitConfig) -> Result<DevKitConfig, String> {
state.set_config(config)?;
Ok(state.get_config())
}
#[tauri::command]
pub async fn devkit_process_export(
state: State<'_, DevKitState>,
csv_path: String,
save_as_xlsx: Option<bool>,
) -> Result<ExportProcessResult, String> {
let config = state.get_config();
let use_xlsx = save_as_xlsx.unwrap_or(config.save_as_xlsx);
state.process_export(&csv_path, use_xlsx).await
}

View File

@@ -1,3 +1,6 @@
pub mod file_explorer; pub mod file_explorer;
pub mod serial; pub mod serial;
pub mod window; pub mod window;
#[cfg(feature = "devkit")]
pub mod devkit;

View File

@@ -0,0 +1,268 @@
//! DevKit gRPC Client
//!
//! Rust 端作为 gRPC client
//! 1. 以 client-streaming 方式推送实时帧SensorPush.Upload
//! 2. 以 unary 方式发送导出文件路径做后处理ExportProcessor.ProcessFile
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use serde::{Deserialize, Serialize};
use super::proto::sensor_push_client::SensorPushClient;
use super::proto::export_processor_client::ExportProcessorClient;
use super::proto::{ProcessRequest, SensorFrame};
// ── DevKit 配置 ────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DevKitConfig {
/// 导出过滤抬起:导出 CSV 后自动调用 Python 做梯度过滤
pub filter_lift_enabled: bool,
/// 以 xlsx 保存Python 处理后输出 xlsx 并删除源 CSV
pub save_as_xlsx: bool,
}
impl Default for DevKitConfig {
fn default() -> Self {
Self {
filter_lift_enabled: true,
save_as_xlsx: false,
}
}
}
impl DevKitConfig {
fn config_path() -> PathBuf {
let base = dirs::config_dir()
.or_else(|| dirs::data_dir())
.unwrap_or_else(|| PathBuf::from("."));
base.join("JE-Skin").join("devkit_config.json")
}
/// 从文件加载配置,失败则返回默认值
pub fn load() -> Self {
let path = Self::config_path();
match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Self::default(),
}
}
/// 保存配置到文件
pub fn save(&self) -> Result<(), String> {
let path = Self::config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(&path, json)
.map_err(|e| format!("Failed to write config: {e}"))?;
Ok(())
}
}
// ── 导出处理结果 ───────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportProcessResult {
pub ok: bool,
pub output_path: String,
pub groups_used: u32,
pub mean_value: f64,
pub threshold: f64,
pub rows_total: u32,
pub rows_kept: u32,
pub message: String,
}
// ── Tauri 状态 ─────────────────────────────────────────────────────
/// DevKit 全局状态,由 Tauri manage
#[derive(Clone)]
pub struct DevKitState {
pub running: Arc<AtomicBool>,
pub port: Arc<std::sync::Mutex<u16>>,
pub frame_count: Arc<AtomicU32>,
pub config: Arc<std::sync::Mutex<DevKitConfig>>,
frame_tx: Arc<std::sync::Mutex<Option<mpsc::Sender<SensorFrame>>>>,
client_handle: Arc<std::sync::Mutex<Option<JoinHandle<()>>>>,
}
impl Default for DevKitState {
fn default() -> Self {
Self {
running: Arc::new(AtomicBool::new(false)),
port: Arc::new(std::sync::Mutex::new(50051)),
frame_count: Arc::new(AtomicU32::new(0)),
config: Arc::new(std::sync::Mutex::new(DevKitConfig::load())),
frame_tx: Arc::new(std::sync::Mutex::new(None)),
client_handle: Arc::new(std::sync::Mutex::new(None)),
}
}
}
/// 前端查询到的状态快照
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DevKitStatusSnapshot {
pub enabled: bool,
pub running: bool,
pub port: u16,
pub frames_sent: u32,
pub config: DevKitConfig,
}
impl DevKitState {
pub fn status(&self) -> DevKitStatusSnapshot {
let cfg = self.config.lock().unwrap().clone();
DevKitStatusSnapshot {
enabled: true,
running: self.running.load(Ordering::SeqCst),
port: *self.port.lock().unwrap(),
frames_sent: self.frame_count.load(Ordering::SeqCst),
config: cfg,
}
}
/// 获取当前配置
pub fn get_config(&self) -> DevKitConfig {
self.config.lock().unwrap().clone()
}
/// 更新配置并持久化
pub fn set_config(&self, new_config: DevKitConfig) -> Result<(), String> {
new_config.save()?;
*self.config.lock().unwrap() = new_config;
Ok(())
}
/// 启动 gRPC client连接到 Python server 并开始推送数据
pub async fn start(&self, port: u16) -> Result<(), String> {
if self.running.load(Ordering::SeqCst) {
return Err("AlreadyRunning".into());
}
let addr = format!("http://127.0.0.1:{port}");
*self.port.lock().unwrap() = port;
self.running.store(true, Ordering::SeqCst);
self.frame_count.store(0, Ordering::SeqCst);
// mpsc channel: 主线程 send 帧 → gRPC task 推送给 Python
let (tx, rx) = mpsc::channel::<SensorFrame>(512);
*self.frame_tx.lock().unwrap() = Some(tx);
let running = Arc::clone(&self.running);
let frame_count = Arc::clone(&self.frame_count);
let handle = tokio::spawn(async move {
if let Err(e) = run_grpc_upload(addr, rx, frame_count).await {
::log::error!("DevKit gRPC upload error: {e:?}");
}
running.store(false, Ordering::SeqCst);
});
*self.client_handle.lock().unwrap() = Some(handle);
::log::info!("DevKit gRPC client started, connecting to 127.0.0.1:{port}");
Ok(())
}
/// 停止 gRPC client
pub async fn stop(&self) -> Result<(), String> {
if !self.running.load(Ordering::SeqCst) {
return Err("NotRunning".into());
}
*self.frame_tx.lock().unwrap() = None;
if let Some(handle) = self.client_handle.lock().unwrap().take() {
handle.abort();
}
self.running.store(false, Ordering::SeqCst);
::log::info!("DevKit gRPC client stopped");
Ok(())
}
/// 推送一帧数据到 gRPC stream由主线程调用
pub fn push_frame(&self, frame: SensorFrame) {
if !self.running.load(Ordering::SeqCst) {
return;
}
if let Some(tx) = self.frame_tx.lock().unwrap().as_ref() {
let _ = tx.try_send(frame);
}
}
/// 调用 Python ExportProcessor.ProcessFile 做导出后处理unary
pub async fn process_export(
&self,
csv_path: &str,
save_as_xlsx: bool,
) -> Result<ExportProcessResult, String> {
let port = *self.port.lock().unwrap();
let addr = format!("http://127.0.0.1:{port}");
let mut client = ExportProcessorClient::connect(addr)
.await
.map_err(|e| format!("Failed to connect to DevKit server: {e}"))?;
let request = ProcessRequest {
csv_path: csv_path.to_string(),
save_as_xlsx,
};
let response = client
.process_file(request)
.await
.map_err(|e| format!("ProcessFile RPC failed: {e}"))?;
let resp = response.into_inner();
Ok(ExportProcessResult {
ok: resp.ok,
output_path: resp.output_path,
groups_used: resp.groups_used,
mean_value: resp.mean_value,
threshold: resp.threshold,
rows_total: resp.rows_total,
rows_kept: resp.rows_kept,
message: resp.message,
})
}
}
// ── gRPC Upload Client ─────────────────────────────────────────────
async fn run_grpc_upload(
addr: String,
mut rx: mpsc::Receiver<SensorFrame>,
frame_count: Arc<AtomicU32>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut client = SensorPushClient::connect(addr.clone()).await?;
let stream = async_stream::stream! {
while let Some(frame) = rx.recv().await {
frame_count.fetch_add(1, Ordering::SeqCst);
yield frame;
}
};
let response = client.upload(stream).await?;
let resp = response.into_inner();
::log::info!(
"DevKit upload complete: ok={}, frames={}, msg={}",
resp.ok,
resp.frames_received,
resp.message
);
Ok(())
}

View File

@@ -0,0 +1,13 @@
//! Develop Kit 模块
//!
//! 仅在 `devkit` feature 启用时编译。
//! Rust 端作为 gRPC client将传感器压力矩阵数据实时推送给 Python gRPC server。
mod client;
pub use client::{DevKitConfig, DevKitState, DevKitStatusSnapshot, ExportProcessResult};
// 导入 tonic 生成的 gRPC 代码
pub mod proto {
tonic::include_proto!("sensor_stream");
}

1250
src-tauri/src/lan_game.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,147 @@
mod commands; mod commands;
pub mod serial_core; mod lan_game;
pub mod log; pub mod log;
pub mod serial_core;
#[cfg(feature = "devkit")]
pub mod devkit;
use commands::serial::SerialConnectionState; use commands::serial::SerialConnectionState;
#[cfg(feature = "devkit")]
use tauri::Manager;
#[cfg(feature = "devkit")]
fn start_server_exe(exe_path: &std::path::Path) {
let mut command = std::process::Command::new(exe_path);
command.arg("--port").arg("50051");
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
match command.spawn() {
Ok(_) => ::log::info!("DevKit Python server started: {}", exe_path.display()),
Err(error) => ::log::warn!("Failed to start DevKit Python server: {error}"),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() let builder = tauri::Builder::default()
.plugin(tauri_plugin_process::init())
.manage(SerialConnectionState::default()) .manage(SerialConnectionState::default())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init());
.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list, #[cfg(not(any(target_os = "android", target_os = "ios")))]
commands::serial::serial_enum, let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
commands::serial::serial_connect,
commands::serial::serial_disconnect, #[cfg(any(target_os = "android", target_os = "ios"))]
commands::serial::serial_export_csv, let builder = builder;
commands::serial::serial_has_record_data,
commands::serial::serial_export_csv_to_path, #[cfg(feature = "devkit")]
commands::serial::serial_import_csv, let builder = {
commands::serial::serial_import_csv_from_path, let devkit_state = devkit::DevKitState::default();
commands::window::win_minimize, let devkit_state_clone = devkit_state.clone();
commands::window::win_toggle_maximize,
commands::window::win_close builder.manage(devkit_state).setup(move |app| {
]) tauri::async_runtime::spawn(async {
if let Err(error) = lan_game::serve().await {
::log::error!("LAN game server failed: {error:?}");
}
});
let resource_dir = app
.path()
.resource_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("./resources"));
tauri::async_runtime::spawn(async move {
#[cfg(target_os = "windows")]
let exe_name = "je-skin-devkit-server.exe";
#[cfg(not(target_os = "windows"))]
let exe_name = "je-skin-devkit-server";
let bundled_exe = resource_dir.join(exe_name);
let fallback_exe = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(|parent| parent.join(exe_name)));
let server_exe = if bundled_exe.exists() {
Some(bundled_exe)
} else {
fallback_exe.filter(|path| path.exists())
};
if let Some(exe_path) = server_exe {
start_server_exe(&exe_path);
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
} else {
::log::info!("DevKit Python server not found, skipping auto-start");
}
if let Err(error) = devkit_state_clone.start(50051).await {
::log::warn!("DevKit auto-start failed: {error}");
} else {
::log::info!("DevKit auto-started on 127.0.0.1:50051");
}
});
Ok(())
})
};
#[cfg(not(feature = "devkit"))]
let builder = builder.setup(|_app| {
tauri::async_runtime::spawn(async {
if let Err(error) = lan_game::serve().await {
::log::error!("LAN game server failed: {error:?}");
}
});
Ok(())
});
#[cfg(feature = "devkit")]
let builder = builder.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list,
commands::serial::serial_enum,
commands::serial::serial_connect,
commands::serial::serial_disconnect,
commands::serial::serial_export_csv,
commands::serial::serial_has_record_data,
commands::serial::serial_export_csv_to_path,
commands::serial::serial_import_csv,
commands::serial::serial_import_csv_from_path,
commands::window::win_minimize,
commands::window::win_toggle_maximize,
commands::window::win_close,
commands::devkit::devkit_status,
commands::devkit::devkit_start,
commands::devkit::devkit_stop,
commands::devkit::devkit_get_config,
commands::devkit::devkit_set_config,
commands::devkit::devkit_process_export
]);
#[cfg(not(feature = "devkit"))]
let builder = builder.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list,
commands::serial::serial_enum,
commands::serial::serial_connect,
commands::serial::serial_disconnect,
commands::serial::serial_export_csv,
commands::serial::serial_has_record_data,
commands::serial::serial_export_csv_to_path,
commands::serial::serial_import_csv,
commands::serial::serial_import_csv_from_path,
commands::window::win_minimize,
commands::window::win_toggle_maximize,
commands::window::win_close
]);
builder
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@@ -3,7 +3,16 @@ use fern::{
Dispatch, Dispatch,
}; };
use log::debug; use log::debug;
use std::time::SystemTime; use std::{path::{Path, PathBuf}, time::SystemTime};
fn log_directory() -> PathBuf {
let base_dir = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
.unwrap_or_else(std::env::temp_dir);
base_dir.join("JE-Skin").join("logs")
}
pub fn setup_logger() { pub fn setup_logger() {
let colors_line = ColoredLevelConfig::new() let colors_line = ColoredLevelConfig::new()
.error(Color::Red) .error(Color::Red)
@@ -38,7 +47,11 @@ pub fn setup_logger() {
// .chain(fern::DateBased::new("program.log", "%Y-%m-%d")) // .chain(fern::DateBased::new("program.log", "%Y-%m-%d"))
// .apply() // .apply()
// .unwrap(); // .unwrap();
let log_path = std::env::temp_dir().join("program.log"); let log_dir = log_directory();
if let Err(error) = std::fs::create_dir_all(&log_dir) {
eprintln!("failed to create log_directory {}: {error}", log_dir.display());
}
// let log_path = std::env::temp_dir().join("program.log");
let file_config = fern::Dispatch::new() let file_config = fern::Dispatch::new()
.format(move |out, message, record| { .format(move |out, message, record| {
out.finish(format_args!( out.finish(format_args!(
@@ -50,7 +63,7 @@ pub fn setup_logger() {
)); ));
}) })
.level(level) .level(level)
.chain(fern::DateBased::new(&log_path, "%Y-%m-%d")); .chain(fern::DateBased::new(log_dir.join("program.log"), "%Y-%m-%d"));
Dispatch::new() Dispatch::new()
.level(log::LevelFilter::Debug) .level(log::LevelFilter::Debug)

View File

@@ -4,15 +4,21 @@ use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame};
use crate::serial_core::model::{HudChartState, HudPacket}; use crate::serial_core::model::{HudChartState, HudPacket};
use crate::serial_core::record::Recording; use crate::serial_core::record::Recording;
use crate::serial_core::record::{FrameTiming, RecordedFrame}; use crate::serial_core::record::{FrameTiming, RecordedFrame};
#[cfg(feature = "devkit")]
use crate::devkit::{proto::SensorFrame, DevKitState};
use anyhow::Result; use anyhow::Result;
use std::future::pending; use std::future::pending;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
#[cfg(feature = "devkit")]
use tauri::Manager;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::{self, Duration, MissedTickBehavior}; use tokio::time::{self, Duration, MissedTickBehavior};
use tokio_serial::SerialStream; use tokio_serial::SerialStream;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
#[cfg(feature = "devkit")]
use std::sync::atomic::Ordering;
pub enum PollMode<F> { pub enum PollMode<F> {
Disable, Disable,
@@ -271,6 +277,8 @@ where
let force = raw_to_g1(summary as u32); let force = raw_to_g1(summary as u32);
chart_state.record_summary(force as f32); chart_state.record_summary(force as f32);
chart_state.record_pressure_matrix(vals.as_slice()); chart_state.record_pressure_matrix(vals.as_slice());
#[cfg(feature = "devkit")]
push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force);
Some(vec![summary]) Some(vec![summary])
} else { } else {
None None
@@ -286,6 +294,58 @@ where
Ok(()) Ok(())
} }
#[cfg(feature = "devkit")]
fn push_devkit_frame(app: &AppHandle, values: &[i32], dts_ms: u64, resultant_force: f64) {
let devkit_state = app.state::<DevKitState>();
if !devkit_state.running.load(Ordering::Relaxed) {
return;
}
let (rows, cols) = infer_matrix_shape(values.len());
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let seq = timestamp_ms;
let matrix = values
.iter()
.map(|value| (*value).max(0) as u32)
.collect::<Vec<_>>();
devkit_state.push_frame(SensorFrame {
seq,
timestamp_ms,
rows,
cols,
matrix,
resultant_force,
dts_ms: dts_ms as u32,
});
}
#[cfg(feature = "devkit")]
fn infer_matrix_shape(len: usize) -> (u32, u32) {
if len == 84 {
return (12, 7);
}
if len == 0 {
return (0, 0);
}
let mut best = (len, 1);
let mut factor = 1usize;
while factor * factor <= len {
if len % factor == 0 {
best = (len / factor, factor);
}
factor += 1;
}
(best.0 as u32, best.1 as u32)
}
fn raw_to_g1(raw: u32) -> f64 { fn raw_to_g1(raw: u32) -> f64 {
const X: [u32; 12] = [ const X: [u32; 12] = [
0, 84402, 117218, 140176, 159126, 175812, 191484, 208758, 224703, 252448, 302361, 352703, 0, 84402, 117218, 140176, 159126, 175812, 191484, 208758, 224703, 252448, 302361, 352703,

View File

@@ -23,6 +23,7 @@
} }
}, },
"bundle": { "bundle": {
"createUpdaterArtifacts": true,
"active": true, "active": true,
"targets": "all", "targets": "all",
"icon": [ "icon": [
@@ -31,6 +32,27 @@
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
],
"windows": {
"nsis": {
"installMode": "both",
"displayLanguageSelector": false,
"installerIcon": "icons/icon.ico"
}
},
"resources": [
"resources/je-skin-devkit-server.exe"
] ]
},
"plugins": {
"updater": {
"windows": {
"installMode": "passive"
},
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkwODM1QkFEODI2NkZENgpSV1RXYnliWXVqVUlDUVRxbjlseDNDNjhQTGpDYis4TEZMeUk2WVhiMEhTRWJhN3hGRnQ3TTJtcwo=",
"endpoints": [
"https://je-skin.cn-nb1.rains3.com/latest.json"
]
}
} }
} }

View File

@@ -0,0 +1,362 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
export let running = false;
export let port = 50051;
export let framesSent = 0;
export let filterLiftEnabled = true;
export let saveAsXlsx = false;
export let locale: "zh-CN" | "en-US" = "zh-CN";
export let lastProcessResult: {
outputPath: string;
groupsUsed: number;
meanValue: number;
threshold: number;
rowsTotal: number;
rowsKept: number;
} | null = null;
const dispatch = createEventDispatcher<{
close: void;
togglefilterlift: void;
togglexlsx: void;
}>();
$: labels = locale === "zh-CN"
? {
title: "DevKit 配置",
status: "状态",
connected: "已连接",
disconnected: "未连接",
port: "端口",
framesSent: "已发送帧",
filterLift: "导出过滤抬起",
filterLiftHint: "导出 CSV 后自动调用 Python 做梯度过滤,过滤掉抬起的小值数据",
saveXlsx: "以 xlsx 保存",
saveXlsxHint: "Python 处理后输出 xlsx 格式并删除源 CSV 文件",
lastResult: "最近一次处理",
output: "输出文件",
groups: "分组数",
mean: "均值",
threshold: "阈值",
rows: "行数",
kept: "保留行数",
}
: {
title: "DevKit Config",
status: "Status",
connected: "Connected",
disconnected: "Disconnected",
port: "Port",
framesSent: "Frames sent",
filterLift: "Filter lift on export",
filterLiftHint: "After CSV export, automatically call Python to filter out small values",
saveXlsx: "Save as xlsx",
saveXlsxHint: "Python outputs xlsx format and deletes the source CSV file",
lastResult: "Last process",
output: "Output",
groups: "Groups",
mean: "Mean",
threshold: "Threshold",
rows: "Rows",
kept: "Kept rows",
};
</script>
<div class="dk-panel">
<header class="dk-head">
<h3 class="dk-title">{labels.title}</h3>
<button type="button" class="dk-close" on:click={() => dispatch("close")} aria-label="Close">
<span></span><span></span>
</button>
</header>
<section class="dk-section">
<div class="dk-status-row">
<span class="dk-dot" class:active={running}></span>
<span class="dk-label">{labels.status}</span>
<span class="dk-value">{running ? labels.connected : labels.disconnected}</span>
</div>
{#if running}
<div class="dk-info-grid">
<div class="dk-info">
<span class="dk-info-label">{labels.port}</span>
<span class="dk-info-value">:{port}</span>
</div>
<div class="dk-info">
<span class="dk-info-label">{labels.framesSent}</span>
<span class="dk-info-value">{framesSent}</span>
</div>
</div>
{/if}
</section>
<section class="dk-section">
<button type="button" class="dk-toggle" class:active={filterLiftEnabled} on:click={() => dispatch("togglefilterlift")}>
<span class="dk-toggle-indicator"></span>
<div class="dk-toggle-text">
<span class="dk-toggle-label">{labels.filterLift}</span>
<span class="dk-toggle-hint">{labels.filterLiftHint}</span>
</div>
</button>
</section>
<section class="dk-section">
<button type="button" class="dk-toggle" class:active={saveAsXlsx} on:click={() => dispatch("togglexlsx")}>
<span class="dk-toggle-indicator"></span>
<div class="dk-toggle-text">
<span class="dk-toggle-label">{labels.saveXlsx}</span>
<span class="dk-toggle-hint">{labels.saveXlsxHint}</span>
</div>
</button>
</section>
{#if lastProcessResult}
<section class="dk-section dk-result">
<p class="dk-section-title">{labels.lastResult}</p>
<div class="dk-result-grid">
<div class="dk-result-item">
<span class="dk-result-label">{labels.output}</span>
<span class="dk-result-value">{lastProcessResult.outputPath}</span>
</div>
<div class="dk-result-item">
<span class="dk-result-label">{labels.groups}</span>
<span class="dk-result-value">{lastProcessResult.groupsUsed}</span>
</div>
<div class="dk-result-item">
<span class="dk-result-label">{labels.mean}</span>
<span class="dk-result-value">{lastProcessResult.meanValue.toFixed(3)}</span>
</div>
<div class="dk-result-item">
<span class="dk-result-label">{labels.threshold}</span>
<span class="dk-result-value">{lastProcessResult.threshold.toFixed(3)}</span>
</div>
<div class="dk-result-item">
<span class="dk-result-label">{labels.rows}</span>
<span class="dk-result-value">{lastProcessResult.rowsTotal}{lastProcessResult.rowsKept}</span>
</div>
</div>
</section>
{/if}
</div>
<style>
.dk-panel {
display: grid;
gap: 0.7rem;
inline-size: min(22rem, 100%);
padding: 0.9rem 0.96rem 1rem;
border: 1px solid rgb(var(--hud-border-rgb) / 0.3);
border-radius: 0.82rem;
background:
linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.92), rgb(var(--hud-surface-deep-rgb) / 0.88)),
radial-gradient(circle at 100% 0, rgb(var(--hud-info-rgb) / 0.07), transparent 38%);
box-shadow:
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.08),
0 18px 46px rgb(0 0 0 / 0.28);
backdrop-filter: blur(10px);
}
.dk-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.dk-title {
margin: 0;
color: rgb(var(--hud-text-main-rgb) / 0.98);
font-size: 0.95rem;
font-weight: 600;
}
.dk-close {
position: relative;
inline-size: 1.8rem;
block-size: 1.8rem;
border: 1px solid rgb(var(--hud-border-rgb) / 0.32);
border-radius: 999px;
background: rgb(var(--hud-surface-deep-rgb) / 0.72);
cursor: pointer;
flex: 0 0 auto;
}
.dk-close span {
position: absolute;
top: 50%;
left: 50%;
inline-size: 0.7rem;
block-size: 1px;
background: rgb(var(--hud-text-main-rgb) / 0.9);
transform-origin: center;
}
.dk-close span:first-child { transform: translate(-50%, -50%) rotate(45deg); }
.dk-close span:last-child { transform: translate(-50%, -50%) rotate(-45deg); }
.dk-section {
display: grid;
gap: 0.5rem;
padding: 0.6rem 0.7rem;
border: 1px solid rgb(var(--hud-border-rgb) / 0.22);
border-radius: 0.68rem;
background: linear-gradient(180deg, rgb(var(--hud-surface-rgb) / 0.76), rgb(var(--hud-surface-deep-rgb) / 0.64));
}
.dk-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dk-dot {
inline-size: 0.5rem;
block-size: 0.5rem;
border-radius: 50%;
background: rgb(var(--hud-text-dim-rgb) / 0.8);
transition: background 200ms ease;
}
.dk-dot.active {
background: rgb(var(--hud-lime-rgb) / 0.95);
box-shadow: 0 0 0 2px rgb(var(--hud-lime-rgb) / 0.14);
}
.dk-label {
color: rgb(var(--hud-text-dim-rgb) / 0.8);
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dk-value {
color: rgb(var(--hud-text-main-rgb) / 0.96);
font-size: 0.82rem;
font-weight: 500;
margin-inline-start: auto;
}
.dk-info-grid {
display: flex;
gap: 1rem;
}
.dk-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.dk-info-label {
color: rgb(var(--hud-text-dim-rgb) / 0.7);
font-size: 0.56rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dk-info-value {
color: rgb(var(--hud-text-main-rgb) / 0.94);
font-size: 0.82rem;
font-weight: 500;
}
.dk-toggle {
display: flex;
align-items: flex-start;
gap: 0.6rem;
border: 1px solid rgb(var(--hud-border-rgb) / 0.24);
border-radius: 0.58rem;
padding: 0.55rem 0.65rem;
background: rgb(var(--hud-surface-rgb) / 0.82);
cursor: pointer;
text-align: left;
transition: border-color 180ms ease, box-shadow 180ms ease;
}
.dk-toggle:hover {
border-color: rgb(var(--hud-cyan-rgb) / 0.36);
}
.dk-toggle.active {
border-color: rgb(var(--hud-lime-rgb) / 0.48);
box-shadow: inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.08), 0 0 14px rgb(var(--hud-glow-alt-rgb) / 0.12);
}
.dk-toggle-indicator {
inline-size: 0.56rem;
block-size: 0.56rem;
min-inline-size: 0.56rem;
border-radius: 50%;
margin-block-start: 0.15rem;
background: rgb(var(--hud-text-dim-rgb) / 0.7);
transition: background 180ms ease;
}
.dk-toggle.active .dk-toggle-indicator {
background: rgb(var(--hud-lime-rgb) / 0.92);
box-shadow: 0 0 0 2px rgb(var(--hud-lime-rgb) / 0.14);
}
.dk-toggle-text {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.dk-toggle-label {
color: rgb(var(--hud-text-main-rgb) / 0.96);
font-size: 0.78rem;
font-weight: 500;
}
.dk-toggle-hint {
color: rgb(var(--hud-text-dim-rgb) / 0.7);
font-size: 0.64rem;
line-height: 1.35;
}
.dk-section-title {
margin: 0;
color: rgb(var(--hud-text-dim-rgb) / 0.8);
font-size: 0.58rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.dk-result {
display: grid;
gap: 0.45rem;
}
.dk-result-grid {
display: grid;
gap: 0.3rem;
}
.dk-result-item {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.2rem 0;
border-bottom: 1px solid rgb(var(--hud-border-rgb) / 0.14);
}
.dk-result-item:last-child {
border-bottom: none;
}
.dk-result-label {
color: rgb(var(--hud-text-dim-rgb) / 0.72);
font-size: 0.64rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.dk-result-value {
color: rgb(var(--hud-text-main-rgb) / 0.94);
font-size: 0.76rem;
font-weight: 500;
text-align: right;
word-break: break-all;
}
</style>

View File

@@ -41,6 +41,10 @@
export let importActionLabel = ""; export let importActionLabel = "";
export let connectionNotice = ""; export let connectionNotice = "";
export let connectionNoticeTone: HudNoticeTone = "info"; export let connectionNoticeTone: HudNoticeTone = "info";
export let noticeConfirmLabel = "";
export let noticeCancelLabel = "";
export let noticeShowActions = false;
export let noticeActionBusy = false;
export let isRefreshingPorts = false; export let isRefreshingPorts = false;
export let isConnectDisabled = false; export let isConnectDisabled = false;
export let isExporting = false; export let isExporting = false;
@@ -58,6 +62,8 @@
serialexport: void; serialexport: void;
csvimport: void; csvimport: void;
noticeclear: void; noticeclear: void;
noticeconfirm: void;
noticecancel: void;
}>(); }>();
const connectionToneByState: Record<ConnectionState, "ok" | "warn" | "idle"> = { const connectionToneByState: Record<ConnectionState, "ok" | "warn" | "idle"> = {
@@ -123,6 +129,14 @@
function emitNoticeClear(): void { function emitNoticeClear(): void {
dispatch("noticeclear"); dispatch("noticeclear");
} }
function emitNoticeConfirm(): void {
dispatch("noticeconfirm");
}
function emitNoticeCancel(): void {
dispatch("noticecancel");
}
</script> </script>
<section class="hud-panel" aria-label={controlAreaLabel}> <section class="hud-panel" aria-label={controlAreaLabel}>
@@ -301,14 +315,25 @@
{#if connectionNotice} {#if connectionNotice}
<div class="connection-notice tone-{connectionNoticeTone}" role={connectionNoticeTone === "warn" ? "alert" : "status"}> <div class="connection-notice tone-{connectionNoticeTone}" role={connectionNoticeTone === "warn" ? "alert" : "status"}>
<p class="connection-notice-text">{connectionNotice}</p> <p class="connection-notice-text">{connectionNotice}</p>
<button {#if noticeShowActions}
type="button" <div class="notice-actions">
class="notice-close-btn" <button type="button" class="notice-action-btn is-primary" disabled={noticeActionBusy} on:click={emitNoticeConfirm}>
aria-label={locale === "zh-CN" ? "关闭提示" : "Dismiss notice"} {noticeActionBusy ? (locale === "zh-CN" ? "处理中..." : "Working...") : noticeConfirmLabel}
on:click={emitNoticeClear} </button>
> <button type="button" class="notice-action-btn" disabled={noticeActionBusy} on:click={emitNoticeCancel}>
× {noticeCancelLabel}
</button> </button>
</div>
{:else}
<button
type="button"
class="notice-close-btn"
aria-label={locale === "zh-CN" ? "关闭提示" : "Dismiss notice"}
on:click={emitNoticeClear}
>
×
</button>
{/if}
</div> </div>
{/if} {/if}
@@ -921,6 +946,58 @@
background: rgb(9 16 22 / 0.92); background: rgb(9 16 22 / 0.92);
} }
.notice-actions {
display: inline-flex;
align-items: center;
gap: 0.42rem;
flex-shrink: 0;
}
.notice-action-btn {
min-block-size: 1.55rem;
border: 1px solid rgb(116 151 176 / 0.38);
border-radius: 999px;
padding: 0.24rem 0.72rem;
background: rgb(7 12 16 / 0.76);
color: rgb(194 225 245 / 0.94);
font-size: 0.68rem;
letter-spacing: 0.04em;
cursor: pointer;
transition:
border-color 180ms ease,
color 180ms ease,
background-color 180ms ease,
box-shadow 200ms ease,
opacity 180ms ease;
}
.notice-action-btn:hover:not(:disabled) {
border-color: rgb(62 232 255 / 0.46);
color: rgb(237 250 255 / 0.98);
background: rgb(9 16 22 / 0.92);
}
.notice-action-btn.is-primary {
border-color: rgb(var(--hud-lime-rgb) / 0.44);
background:
linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.94), rgb(var(--hud-surface-rgb) / 0.88)),
radial-gradient(circle at 50% 0, rgb(var(--hud-lime-rgb) / 0.14), transparent 58%);
color: rgb(var(--hud-text-main-rgb) / 0.98);
box-shadow: 0 0 10px rgb(var(--hud-lime-rgb) / 0.08);
}
.notice-action-btn.is-primary:hover:not(:disabled) {
border-color: rgb(var(--hud-lime-rgb) / 0.62);
box-shadow:
inset 0 0 0 1px rgb(var(--hud-text-main-rgb) / 0.08),
0 0 13px rgb(var(--hud-lime-rgb) / 0.14);
}
.notice-action-btn:disabled {
cursor: default;
opacity: 0.64;
}
.connection-notice.tone-warn .notice-close-btn:hover { .connection-notice.tone-warn .notice-close-btn:hover {
border-color: rgb(255 91 63 / 0.6); border-color: rgb(255 91 63 / 0.6);
color: rgb(255 227 220 / 0.98); color: rgb(255 227 220 / 0.98);

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,2 @@
export const DEFAULT_PRESSURE_RANGE_MIN = 0; export const DEFAULT_PRESSURE_RANGE_MIN = 0;
export const DEFAULT_PRESSURE_RANGE_MAX = 6000; export const DEFAULT_PRESSURE_RANGE_MAX = 7000;

View File

@@ -3,9 +3,12 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { LogicalSize, currentMonitor, getCurrentWindow } from "@tauri-apps/api/window"; import { LogicalSize, currentMonitor, getCurrentWindow } from "@tauri-apps/api/window";
import { relaunch } from "@tauri-apps/plugin-process";
import { check } from "@tauri-apps/plugin-updater";
import HudPanel from "$lib/components/HudPanel.svelte"; import HudPanel from "$lib/components/HudPanel.svelte";
import CenterStage from "$lib/components/CenterStage.svelte"; import CenterStage from "$lib/components/CenterStage.svelte";
import FileExplorerModal from "$lib/components/FileExplorerModal.svelte"; import FileExplorerModal from "$lib/components/FileExplorerModal.svelte";
import DevKitConfigPanel from "$lib/components/DevKitConfigPanel.svelte";
import { DEFAULT_PRESSURE_RANGE_MAX, DEFAULT_PRESSURE_RANGE_MIN } from "$lib/config/pressure-range"; import { DEFAULT_PRESSURE_RANGE_MAX, DEFAULT_PRESSURE_RANGE_MIN } from "$lib/config/pressure-range";
import { pressureColorPalettes } from "$lib/config/color-map"; import { pressureColorPalettes } from "$lib/config/color-map";
import "$lib/styles/theme.css"; import "$lib/styles/theme.css";
@@ -203,6 +206,9 @@
let isRefreshingPorts = false; let isRefreshingPorts = false;
let connectionNotice = ""; let connectionNotice = "";
let connectionNoticeTone: HudNoticeTone = "info"; let connectionNoticeTone: HudNoticeTone = "info";
let updateNoticeVisible = false;
let updateInstallBusy = false;
let pendingUpdate: Awaited<ReturnType<typeof check>> | null = null;
let isExporting = false; let isExporting = false;
let deviceValue = "JE-Skin-F"; let deviceValue = "JE-Skin-F";
let sampleRateValue = "100Hz"; let sampleRateValue = "100Hz";
@@ -238,6 +244,22 @@
let fileExplorerRoots: FileExplorerRoot[] = []; let fileExplorerRoots: FileExplorerRoot[] = [];
let fileExplorerSelectedPath = ""; let fileExplorerSelectedPath = "";
let fileExplorerFileName = ""; let fileExplorerFileName = "";
let isDevKitConfigOpen = false;
let devkitEnabled = false;
let devkitRunning = false;
let devkitPort = 50051;
let devkitFramesSent = 0;
let devkitFilterLift = true;
let devkitSaveXlsx = false;
let devkitLastResult: {
outputPath: string;
groupsUsed: number;
meanValue: number;
threshold: number;
rowsTotal: number;
rowsKept: number;
} | null = null;
let devkitStatusTimer: number | null = null;
$: uiCopy = copyByLocale[locale]; $: uiCopy = copyByLocale[locale];
$: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen, isPrecisionTestOpen); $: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen, isPrecisionTestOpen);
@@ -1012,7 +1034,7 @@
settings: "Setup" settings: "Setup"
}; };
return [ const links: HudConfigLink[] = [
{ {
id: "stream-on", id: "stream-on",
label: labels.streamOn, label: labels.streamOn,
@@ -1044,6 +1066,17 @@
active: isSettingsOpen active: isSettingsOpen
} }
]; ];
if (devkitEnabled) {
links.push({
id: "devkit",
label: "DevKit",
tone: "cyan",
active: isDevKitConfigOpen
});
}
return links;
} }
async function ensureDefaultWindowSize(): Promise<void> { async function ensureDefaultWindowSize(): Promise<void> {
@@ -1097,6 +1130,71 @@
const context = canvas.getContext("webgl2"); const context = canvas.getContext("webgl2");
} }
async function checkForAppUpdate(): Promise<void> {
if (!isTauriRuntime()) {
return;
}
const updateDismissKey = "je-skin-update-dismissed-version";
try {
const update = await check();
if (!update) {
return;
}
if (window.sessionStorage.getItem(updateDismissKey) === update.version) {
return;
}
const message =
locale === "zh-CN"
? `发现新版本 ${update.version},是否现在下载并安装?`
: `Version ${update.version} is available. Download and install now?`;
pendingUpdate = update;
updateNoticeVisible = true;
updateInstallBusy = false;
connectionNotice = message;
connectionNoticeTone = "info";
} catch (error) {
console.error("App update check failed:", error);
}
}
async function handleUpdateConfirm(): Promise<void> {
if (!pendingUpdate || updateInstallBusy) {
return;
}
updateInstallBusy = true;
connectionNotice = locale === "zh-CN" ? "正在下载并安装更新..." : "Downloading and installing update...";
connectionNoticeTone = "info";
try {
await pendingUpdate.downloadAndInstall();
await relaunch();
} catch (error) {
updateInstallBusy = false;
updateNoticeVisible = false;
pendingUpdate = null;
connectionNotice = locale === "zh-CN" ? "更新安装失败,请稍后重试。" : "Update failed. Please try again later.";
connectionNoticeTone = "warn";
console.error("App update install failed:", error);
}
}
function handleUpdateCancel(): void {
if (pendingUpdate) {
window.sessionStorage.setItem("je-skin-update-dismissed-version", pendingUpdate.version);
}
pendingUpdate = null;
updateNoticeVisible = false;
updateInstallBusy = false;
connectionNotice = "";
}
function handleLocaleChange(event: CustomEvent<LocaleCode>): void { function handleLocaleChange(event: CustomEvent<LocaleCode>): void {
locale = event.detail; locale = event.detail;
} }
@@ -1318,6 +1416,57 @@
? await invoke<SerialExportResult>("serial_export_csv_to_path", { filePath }) ? await invoke<SerialExportResult>("serial_export_csv_to_path", { filePath })
: await invoke<SerialExportResult>("serial_export_csv"); : await invoke<SerialExportResult>("serial_export_csv");
if (devkitEnabled && devkitRunning && devkitFilterLift) {
try {
const processResult = await invoke<{
ok: boolean;
outputPath: string;
groupsUsed: number;
meanValue: number;
threshold: number;
rowsTotal: number;
rowsKept: number;
message: string;
}>("devkit_process_export", {
csvPath: result.path,
saveAsXlsx: devkitSaveXlsx
});
if (processResult.ok) {
devkitLastResult = {
outputPath: processResult.outputPath,
groupsUsed: processResult.groupsUsed,
meanValue: processResult.meanValue,
threshold: processResult.threshold,
rowsTotal: processResult.rowsTotal,
rowsKept: processResult.rowsKept
};
connectionNotice =
locale === "zh-CN"
? `CSV 已导出并完成 DevKit 处理(${result.frameCount} 帧):${processResult.outputPath}`
: `CSV exported and processed by DevKit (${result.frameCount} frames): ${processResult.outputPath}`;
connectionNoticeTone = "ok";
return true;
}
connectionNotice =
locale === "zh-CN"
? `CSV 已导出,但 DevKit 处理失败:${processResult.message}`
: `CSV exported, but DevKit processing failed: ${processResult.message}`;
connectionNoticeTone = "warn";
return true;
} catch (error) {
connectionNotice =
locale === "zh-CN"
? "CSV 已导出,但 DevKit 后处理调用失败。"
: "CSV exported, but DevKit post-processing failed.";
connectionNoticeTone = "warn";
console.error("DevKit export post-process failed:", error);
return true;
}
}
connectionNotice = connectionNotice =
locale === "zh-CN" locale === "zh-CN"
? `CSV 导出成功(${result.frameCount} 帧):${result.path}` ? `CSV 导出成功(${result.frameCount} 帧):${result.path}`
@@ -1477,17 +1626,27 @@
if (event.detail === "precision-test") { if (event.detail === "precision-test") {
isPrecisionTestOpen = !isPrecisionTestOpen; isPrecisionTestOpen = !isPrecisionTestOpen;
isConfigPanelOpen = false; isConfigPanelOpen = false;
isDevKitConfigOpen = false;
return; return;
} }
if (event.detail === "settings") { if (event.detail === "settings") {
isPrecisionTestOpen = false; isPrecisionTestOpen = false;
isConfigPanelOpen = !isConfigPanelOpen; isConfigPanelOpen = !isConfigPanelOpen;
isDevKitConfigOpen = false;
return;
}
if (event.detail === "devkit") {
isPrecisionTestOpen = false;
isConfigPanelOpen = false;
isDevKitConfigOpen = !isDevKitConfigOpen;
return; return;
} }
isPrecisionTestOpen = false; isPrecisionTestOpen = false;
isConfigPanelOpen = false; isConfigPanelOpen = false;
isDevKitConfigOpen = false;
activeConfigLinkId = event.detail; activeConfigLinkId = event.detail;
console.info("[hud] config link clicked:", event.detail); console.info("[hud] config link clicked:", event.detail);
} }
@@ -1511,6 +1670,55 @@
} }
} }
// ── DevKit Functions ────────────────────────────────────────────
async function pollDevKitStatus(): Promise<void> {
if (!isTauriRuntime()) return;
try {
const status = await invoke<{
enabled: boolean;
running: boolean;
port: number;
framesSent: number;
config: { filterLiftEnabled: boolean; saveAsXlsx: boolean };
}>("devkit_status");
devkitEnabled = status.enabled;
devkitRunning = status.running;
devkitPort = status.port;
devkitFramesSent = status.framesSent;
devkitFilterLift = status.config.filterLiftEnabled;
devkitSaveXlsx = status.config.saveAsXlsx;
} catch {
devkitEnabled = false;
devkitRunning = false;
isDevKitConfigOpen = false;
}
}
async function handleDevKitToggleFilterLift(): Promise<void> {
if (!isTauriRuntime()) return;
try {
const newConfig = { filterLiftEnabled: !devkitFilterLift, saveAsXlsx: devkitSaveXlsx };
const result = await invoke<{ filterLiftEnabled: boolean; saveAsXlsx: boolean }>("devkit_set_config", { config: newConfig });
devkitFilterLift = result.filterLiftEnabled;
devkitSaveXlsx = result.saveAsXlsx;
} catch (error) {
console.error("DevKit config update failed:", error);
}
}
async function handleDevKitToggleXlsx(): Promise<void> {
if (!isTauriRuntime()) return;
try {
const newConfig = { filterLiftEnabled: devkitFilterLift, saveAsXlsx: !devkitSaveXlsx };
const result = await invoke<{ filterLiftEnabled: boolean; saveAsXlsx: boolean }>("devkit_set_config", { config: newConfig });
devkitFilterLift = result.filterLiftEnabled;
devkitSaveXlsx = result.saveAsXlsx;
} catch (error) {
console.error("DevKit config update failed:", error);
}
}
function handleMatrixDisplayToggle(event: CustomEvent<boolean>): void { function handleMatrixDisplayToggle(event: CustomEvent<boolean>): void {
matrixDisplayMode = event.detail ? "dots" : "numeric"; matrixDisplayMode = event.detail ? "dots" : "numeric";
} }
@@ -1526,6 +1734,9 @@
if (isTauriRuntime()) { if (isTauriRuntime()) {
void refreshSerialPorts(); void refreshSerialPorts();
void checkForAppUpdate();
void pollDevKitStatus();
devkitStatusTimer = window.setInterval(() => void pollDevKitStatus(), 3000);
void startTauriHudStream(applyPacket) void startTauriHudStream(applyPacket)
.then((unlisten) => { .then((unlisten) => {
if (disposed) { if (disposed) {
@@ -1547,6 +1758,10 @@
pauseReplayPlayback(); pauseReplayPlayback();
stopMockFeed?.(); stopMockFeed?.();
unlistenHudStream?.(); unlistenHudStream?.();
if (devkitStatusTimer != null) {
window.clearInterval(devkitStatusTimer);
devkitStatusTimer = null;
}
}; };
}); });
</script> </script>
@@ -1591,6 +1806,10 @@
importActionLabel={uiCopy.importActionLabel} importActionLabel={uiCopy.importActionLabel}
{connectionNotice} {connectionNotice}
{connectionNoticeTone} {connectionNoticeTone}
noticeConfirmLabel={locale === "zh-CN" ? "确定" : "Confirm"}
noticeCancelLabel={locale === "zh-CN" ? "取消" : "Cancel"}
noticeShowActions={updateNoticeVisible}
noticeActionBusy={updateInstallBusy}
{configLinks} {configLinks}
{isRefreshingPorts} {isRefreshingPorts}
{isExporting} {isExporting}
@@ -1606,7 +1825,12 @@
on:serialconnect={handleSerialConnect} on:serialconnect={handleSerialConnect}
on:serialexport={handleSerialExportRequest} on:serialexport={handleSerialExportRequest}
on:csvimport={handleReplayImportRequest} on:csvimport={handleReplayImportRequest}
on:noticeclear={() => (connectionNotice = "")} on:noticeclear={() => {
connectionNotice = "";
updateNoticeVisible = false;
}}
on:noticeconfirm={handleUpdateConfirm}
on:noticecancel={handleUpdateCancel}
/> />
<CenterStage <CenterStage
@@ -1693,6 +1917,25 @@
on:navigate={handleFileExplorerNavigate} on:navigate={handleFileExplorerNavigate}
on:confirm={handleFileExplorerConfirm} on:confirm={handleFileExplorerConfirm}
/> />
{#if isDevKitConfigOpen && devkitEnabled}
<div class="devkit-overlay" role="dialog" aria-label="DevKit Config">
<div class="devkit-float">
<DevKitConfigPanel
running={devkitRunning}
port={devkitPort}
framesSent={devkitFramesSent}
filterLiftEnabled={devkitFilterLift}
saveAsXlsx={devkitSaveXlsx}
locale={locale}
lastProcessResult={devkitLastResult}
on:close={() => (isDevKitConfigOpen = false)}
on:togglefilterlift={handleDevKitToggleFilterLift}
on:togglexlsx={handleDevKitToggleXlsx}
/>
</div>
</div>
{/if}
</main> </main>
<style> <style>
@@ -1871,4 +2114,20 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.devkit-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 0.5);
backdrop-filter: blur(6px);
}
.devkit-float {
position: relative;
z-index: 1;
}
</style> </style>