Browse Source

feat(d2-02-t3): operate调试页接入标定协作+保存per-well范围(功能版,compile-pending:operate.exe卡死待重启验证)

Task3.4 VM接协作:
- ComHouseInit 先经 control HTTP /debug/acquire 借该舱拿 sessionId(存 CurrentSessionId)+构造 CalibrationClient(放本地HAL借用之前,因operate进程HAL空壳借不到真硬件,真硬件在control独立进程)
- OneClickCalibrate 改写为协作版:StartAsync+800ms轮询 PollProgressAsync,每孔 WellCalibProgressDto(State数字枚举0-4)映射到16格 CalibResults
- 新增 RecalibrateWell(单孔重标 RecalibrateAsync)/StopCalibrate改调 StopAsync+停轮询
- 旧本地 CalibrationEngine+CalibrationStore 自标定+写JSON/scene0 全移除(标定改由control执行落库),清理无用 using
- ComHouseUnit 加停轮询+ReleaseAsync(后台best-effort,避免control失联时HttpClient默认100s超时卡UI)

Task3.5 保存范围(照 SaveManualLayerTune 范式):
- 新增 SaveWellFocusRange:body{tlSn,houseSn,wellSn,horizontalFocusRange,verticalFocusRange}经 WellUpdateApi 发/well/update,留空=null继承设备级;字段名与Java HouseWellSettingUpdate完全一致
- 新增 CurrentHFocusRange/CurrentVFocusRange 属性 + LoadWellFocusRange 回填

View增量(不重写):
- 16格tile加'重标'按钮(Tag=Well,Click=RecalibrateWell_Click)
- 手调面板下加对焦半幅行(水平/垂直半幅输入框+载入/保存范围按钮)
- xaml.cs加 LoadFocusRange_Click/SaveFocusRange_Click/RecalibrateWell_Click

⚠ compile-pending:operate.exe(PID20268)锁DLL无法build operate sln,本次仅逐文件人工核对签名(CalibrationClient/DebugSessionClient/WellUpdateApi/绑定属性),未编译验证,待operate重启后编译

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 2 days ago
parent
commit
6e57d0430f

+ 14 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml

@@ -451,6 +451,9 @@
                                     <TextBlock Text="{Binding FocusZ, StringFormat='Z={0}'}" FontSize="16"/>
                                     <TextBlock Text="{Binding PeakRatio, StringFormat='峰比={0:F2}'}" FontSize="16"/>
                                     <TextBlock Text="{Binding CenterOffsetPct, StringFormat='偏移={0:F1}%'}" FontSize="16"/>
+                                    <!-- D2-02-T3.4 逐孔微调:点该格"重标"经 control 协作单孔重标该 well。 -->
+                                    <Button Content="重标" FontSize="14" Height="32" Margin="0 2 0 0"
+                                            Tag="{Binding Well}" Click="RecalibrateWell_Click"/>
                                 </StackPanel>
                             </Border>
                         </DataTemplate>
@@ -486,6 +489,17 @@
                         <customControl:ButtonCornerRadius x:Name="_previewManualTune_Button" Click="PreviewManualTune_Click" Content="预览各层位置" FontSize="20" Width="200" Height="70" CornerRadius="12" Background="#BFD87D" EnabledBackground="#bdbdbd" BorderThickness="0" Margin="0 0 16 0"/>
                         <customControl:ButtonCornerRadius x:Name="_saveManualTune_Button" Click="SaveManualTune_Click" Content="存为该well默认" FontSize="20" Width="220" Height="70" CornerRadius="12" Background="#BFD87D" EnabledBackground="#bdbdbd" BorderThickness="0"/>
                     </StackPanel>
+
+                    <!-- D2-02-T3.5 per-well 对焦搜索半幅(留空继承设备级)。锚点 well 复用上方 ManualTuneWell。 -->
+                    <TextBlock Text="对焦搜索半幅(留空继承设备级;针对上方所选 well)" FontSize="22" FontWeight="Bold" Margin="0 12 0 4"/>
+                    <StackPanel Orientation="Horizontal" Margin="0 4">
+                        <TextBlock Text="水平半幅" FontSize="22" VerticalAlignment="Center"/>
+                        <TextBox x:Name="_hFocusRange_TextBox" Text="{Binding CurrentHFocusRange, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Width="120" FontSize="22" VerticalContentAlignment="Center" Margin="8 0"/>
+                        <TextBlock Text="垂直半幅" FontSize="22" VerticalAlignment="Center" Margin="16 0 0 0"/>
+                        <TextBox x:Name="_vFocusRange_TextBox" Text="{Binding CurrentVFocusRange, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Width="120" FontSize="22" VerticalContentAlignment="Center" Margin="8 0"/>
+                        <customControl:ButtonCornerRadius x:Name="_loadFocusRange_Button" Click="LoadFocusRange_Click" Content="载入半幅" FontSize="20" Width="150" Height="60" CornerRadius="12" Background="#7DB5D8" EnabledBackground="#bdbdbd" BorderThickness="0" Margin="16 0 0 0"/>
+                        <customControl:ButtonCornerRadius x:Name="_saveFocusRange_Button" Click="SaveFocusRange_Click" Content="保存范围" FontSize="20" Width="170" Height="60" CornerRadius="12" Background="#BFD87D" EnabledBackground="#bdbdbd" BorderThickness="0" Margin="12 0 0 0"/>
+                    </StackPanel>
                 </StackPanel>
                 <!-- 实时预览各层 Z 位置(清晰层 IsFocusLayer 高亮绿) -->
                 <ListBox Grid.Column="1" ItemsSource="{Binding LayerPreview}" Margin="20 0 0 0" Height="240" FontSize="20">

+ 64 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml.cs

@@ -1166,6 +1166,70 @@ namespace ivf_tl_Operate.View
             }
         }
 
+        /// <summary>D2-02-T3.5 载入选定 well 当前 well 级对焦半幅覆盖值(空=继承设备级)。</summary>
+        private void LoadFocusRange_Click(object sender, RoutedEventArgs e)
+        {
+            try
+            {
+                if (vm.CurrentHouse == null)
+                {
+                    ShowMessageDefeat("获取舱室信息失败,请先初始化舱室");
+                    return;
+                }
+                if (vm.ManualTuneWell < 1 || vm.ManualTuneWell > 16)
+                {
+                    ShowMessageDefeat("well 只能为 1-16");
+                    return;
+                }
+                vm.LoadWellFocusRange(vm.ManualTuneWell);
+            }
+            catch (Exception ex)
+            {
+                ExLog(ex, "LoadFocusRange_Click");
+            }
+        }
+
+        /// <summary>D2-02-T3.5 保存该 well 对焦搜索半幅(写 house_well_setting well 级覆盖,留空继承设备级)。</summary>
+        private void SaveFocusRange_Click(object sender, RoutedEventArgs e)
+        {
+            try
+            {
+                if (vm.CurrentHouse == null)
+                {
+                    ShowMessageDefeat("获取舱室信息失败,请先初始化舱室");
+                    return;
+                }
+                if (vm.SaveWellFocusRange())
+                {
+                    ShowMessageSuccess("对焦搜索半幅已保存为该 well 覆盖");
+                }
+                else
+                {
+                    ShowMessageDefeat("对焦半幅保存失败(见日志,可能为非法值或接口失败)");
+                }
+            }
+            catch (Exception ex)
+            {
+                ExLog(ex, "SaveFocusRange_Click");
+            }
+        }
+
+        /// <summary>D2-02-T3.4 逐孔微调:对该格 well 经 control 协作单孔重标。</summary>
+        private async void RecalibrateWell_Click(object sender, RoutedEventArgs e)
+        {
+            try
+            {
+                if (!(sender is FrameworkElement fe) || fe.Tag == null) return;
+                if (!int.TryParse(fe.Tag.ToString(), out int well)) return;
+                await vm.RecalibrateWell(well);
+            }
+            catch (Exception ex)
+            {
+                ExLog(ex, "RecalibrateWell_Click");
+                AddMessageInfo($"单孔重标异常:{ex.Message}");
+            }
+        }
+
         private void OpenVideo_Click(object sender, RoutedEventArgs e)
         {
             try

+ 390 - 146
ivf_tl_operate_2.0/ivf_tl_Operate/ViewModel/HouseDebugPageViewModel.cs

@@ -5,12 +5,12 @@ using ivf_tl_Entity.DebugEntitys;
 using ivf_tl_Entity.DTO;
 using ivf_tl_Entity.GlobalEnums;
 using ivf_tl_Operate.Converts;
+using ivf_tl_Operate.Debug;
+using ivf_tl_Operate.Helpers;
 using ivf_tl_Services;
 using IvfTl.Hardware;
 using IvfTl.Hardware.Impl;
-using IvfTl.AutoFocus.Calib;
 using IvfTl.AutoFocus.Layout;
-using IvfTl.AutoFocus.Storage;
 using Newtonsoft.Json;
 using Newtonsoft.Json.Linq;
 using System;
@@ -56,6 +56,23 @@ namespace ivf_tl_Operate.ViewModel
         /// </summary>
         private IHardwareLease _halLease = null;
 
+        // ── 自动对焦重构 D2-02-T3.4:调试页标定改"经 control 协作",不再 operate 本地自建 CalibrationEngine ──
+        /// <summary>
+        /// 调试会话客户端:经 control 本地 HTTP /debug/acquire 借该舱硬件,拿 sessionId(存 CurrentSessionId)。
+        /// control 独立进程持有真硬件;operate 进程内 HAL 是空壳(从不 ScanDevices),故借硬件/预览/标定全走此 HTTP 协作。
+        /// 借到后起心跳;ComHouseUnit 归还。会话失效回调提示重进调试。
+        /// </summary>
+        private DebugSessionClient _sessionClient;
+
+        /// <summary>
+        /// 16 孔标定协作客户端:复用同一 sessionId,封 control /debug/calibrate/{start,progress,recalibrate,stop}。
+        /// acquire 成功后构造;ComHouseUnit 释放。
+        /// </summary>
+        private CalibrationClient _calibClient;
+
+        /// <summary>标定进度轮询任务的取消源(IsRunning 转 false 或停止标定时取消)。</summary>
+        private System.Threading.CancellationTokenSource _calibPollCts;
+
         /// <summary>
         /// 当前housesn
         /// </summary>
@@ -251,6 +268,16 @@ namespace ivf_tl_Operate.ViewModel
                 try
                 {
                     var currentHouse = HouseList.FirstOrDefault(x => x.houseSn == CurrentHouseId);
+                    if (currentHouse == null)
+                    {
+                        AddMessageInfo($"[{CurrentHouseId}]未获取到舱室信息,无法进入调试");
+                        return;
+                    }
+
+                    // D2-02-T3.4:先经 control HTTP 协作借该舱调试会话拿 sessionId(预览/标定/会话命令均依赖)。
+                    // control 独立进程持真硬件;operate 进程内 HAL 是空壳(从不 ScanDevices)、下面的本地 lease.Serial 取不到,
+                    // 故借硬件的"真正入口"是这里的 HTTP acquire。放在本地 HAL 借用之前,确保 sessionId 一定先建立。
+                    await AcquireDebugSessionAsync(currentHouse.houseSn);
 
                     // M1-03/B2 HAL: 向 HAL 申请该舱前台借用——HAL 暂停 control 对本舱采集(T1.3)并让采集端让出串口/相机,
                     // 调试页随后复用借用到的同一物理句柄(lease.Serial/lease.Camera),不再 new 第二个 ComBin/Camera 开同口/同相机。
@@ -359,6 +386,10 @@ namespace ivf_tl_Operate.ViewModel
         {
             try
             {
+                // D2-02-T3.4:先停标定轮询、归还 control 协作会话(停心跳),再归还本地 HAL 借用。
+                StopCalibPolling();
+                ReleaseDebugSession();
+
                 // M1-B2:物理串口/相机由采集端(control)持有,调试只归还借用,绝不 ClosePort/UnInit 关物理口——
                 // 否则会把采集端的口/相机一并关掉。归还后 HAL 触发 ResumeCapture 恢复本舱采集(T1.3)。
                 if (_halLease != null)
@@ -377,6 +408,75 @@ namespace ivf_tl_Operate.ViewModel
             }
         }
 
+        #region D2-02-T3.4 调试会话协作(借硬件拿 sessionId / 归还)
+
+        /// <summary>
+        /// 向 control 借该舱调试会话:POST /debug/acquire {houseSn} → 拿 sessionId 存 CurrentSessionId,
+        /// 并构造 16 孔标定协作客户端 CalibrationClient(复用同一 sessionId)。
+        /// control 借用成功会起心跳;会话失效(SESSION_EXPIRED)回调提示工程师重进调试。
+        /// 借不到(BUSY/会话异常)只提示不抛——预览/标定将因 CurrentSessionId 为空而拒绝执行。
+        /// </summary>
+        private async Task AcquireDebugSessionAsync(int houseSn)
+        {
+            try
+            {
+                // 关旧会话(重复初始化时)。
+                ReleaseDebugSession();
+
+                var client = new DebugSessionClient(ControlClient.BaseUrl);
+                client.OnSessionExpired = () =>
+                {
+                    CurrentSessionId = null;
+                    AddMessageInfo("⚠ 调试会话已失效(control 端超时回收),请退出后重新进入调试");
+                };
+
+                var r = await client.AcquireAsync(houseSn);
+                if (!r.Ok || string.IsNullOrEmpty(r.SessionId))
+                {
+                    try { client.Dispose(); } catch { }
+                    AddMessageInfo($"[{houseSn}]借调试会话失败:{r.Code}/{r.Error}(预览与标定不可用,请确认 control 已托管该舱)");
+                    return;
+                }
+
+                _sessionClient = client;
+                CurrentSessionId = r.SessionId;
+                // 标定协作客户端复用同一 baseUrl + sessionId。
+                _calibClient = new CalibrationClient(ControlClient.BaseUrl, r.SessionId);
+
+                string cultMsg = r.Cultivating ? $"(注意:该舱培养中,胚胎 {r.EmbryoCount} 枚)" : "";
+                AddMessageInfo($"[{houseSn}]调试会话已建立 sessionId={r.SessionId}{cultMsg}");
+            }
+            catch (Exception ex)
+            {
+                ExLog(ex, "AcquireDebugSessionAsync");
+                AddMessageInfo($"[{houseSn}]借调试会话异常:{ex.Message}");
+            }
+        }
+
+        /// <summary>归还调试会话(停心跳、通知 control)并释放协作客户端。幂等。
+        /// release 的 HTTP POST 放后台跑(避免 control 失联时 HttpClient 默认 100s 超时卡死 UI 线程);
+        /// DebugSessionClient.ReleaseAsync 在 await POST 前已同步置闸+停心跳,故心跳即时停止、不必等 POST。</summary>
+        private void ReleaseDebugSession()
+        {
+            var calib = _calibClient; _calibClient = null;
+            if (calib != null) { try { calib.Dispose(); } catch { } }
+
+            var sc = _sessionClient; _sessionClient = null;
+            if (sc != null)
+            {
+                // 后台 best-effort 归还+释放;失败也无妨(control 端 TTL 看门狗会超时回收会话)。
+                Task.Run(async () =>
+                {
+                    try { await sc.ReleaseAsync(); } catch { }
+                    try { sc.Dispose(); } catch { }
+                });
+            }
+            CurrentSessionId = null;
+        }
+
+        #endregion
+
+
         /// <summary>
         /// 读温度
         /// </summary>
@@ -1117,194 +1217,235 @@ namespace ivf_tl_Operate.ViewModel
             AddPicEvent?.Invoke(picFullName, CurrentWell, CurrentFocal);
         }
 
-        #region M2-05 场景A 一键全自动标定(沙盒
+        #region 场景A 一键全自动标定(经 control 协作,D2-02-T3.4 重构
 
         /// <summary>
-        /// M2-05 场景A 工程师调试页一键全自动标定(人盯安全沙盒,03 §2/§7.1)。
-        /// 流程:对选中 well 列表逐 well 跑 M2-01 四步标定引擎(CalibrationEngine) →
-        ///       收集 WellCalib → 实时 UI 合格绿/伪峰红 → 结果作出厂基准 scene=0 落库 + 写 calibration.json。
-        ///
-        /// 硬件复用:调试页 ComHouseInit 已经 Acquire(OperateDebug) 前台借用(HAL 暂停 control 采集),
-        ///   借用到的 lease.Serial/lease.Camera 即与采集端同一物理句柄(M1-B2)。标定引擎直接用这两个 HAL 接口,
-        ///   不再 new ComBin/Camera、不二次 Open/Init 同 COM 口/相机。互斥由调试页持有的前台借用保证。
-        /// 中止:IsStopCalibrate 标志,每 well 间检查,工程师可随时中止,已完成 well 结果保留。
-        /// 异常:单 well 失败/存储失败不崩 UI,记日志继续;整体异常兜底,IsCalibrating 复位。
+        /// 一键全自动标定(D2-02-T3.4 重构为"经 control 协作")。
+        /// 旧实现:operate 本地 new CalibrationEngine 逐 well 跑 + CalibrationStore 写 JSON/scene0(已停用)。
+        /// 新实现:operate 仅发 /debug/calibrate/start(经 CalibrationClient,复用 acquire 的 sessionId) →
+        ///        起轮询 /debug/calibrate/progress(~800ms) 把每孔状态映射到 16 格 UI(合格绿/伪峰橙/失败红 + FocusZ/峰比/偏移)。
+        ///        标定由 control 协作端真正执行(持真硬件)、scene=0 基准由 control 端落库。
+        /// 前提:ComHouseInit 已成功 acquire(CurrentSessionId 非空、_calibClient 已构造)。
+        /// 中止:StopCalibrate → /debug/calibrate/stop + 停轮询。
         ///
-        /// ⚠ 待验证 V-055..V-059:16well 跑通(关联 V-004 算法严谨性)/合格绿伪峰红/基准scene0落库+JSON/
-        ///   借用期间采集暂停/中止响应。
+        /// ⚠ 待验证(compile-pending,operate.exe 卡死未编译):start/progress/stop 协作往返、16 格映射、合格判定一致。
         /// </summary>
         /// <param name="wells">勾选的 well 列表(空/null 视为全 16 well)。</param>
         public async Task OneClickCalibrate(IEnumerable<int> wells = null)
         {
-            await Task.Run(() =>
+            if (IsCalibrating)
             {
-                if (IsCalibrating)
-                {
-                    AddMessageInfo("[一键标定]已在标定中,请勿重复触发");
-                    return;
-                }
-                if (Serial == null || Cam == null)
-                {
-                    AddMessageInfo("[一键标定]请先初始化舱室(串口/相机未就绪)");
-                    return;
-                }
-                if (CurrentHouse == null)
-                {
-                    AddMessageInfo("[一键标定]未获取到舱室信息");
-                    return;
-                }
+                AddMessageInfo("[一键标定]已在标定中,请勿重复触发");
+                return;
+            }
+            if (_calibClient == null || string.IsNullOrEmpty(CurrentSessionId))
+            {
+                AddMessageInfo("[一键标定]未建立调试会话(请先初始化舱室借硬件),无法发起协作标定");
+                return;
+            }
+            if (CurrentHouse == null)
+            {
+                AddMessageInfo("[一键标定]未获取到舱室信息");
+                return;
+            }
 
-                IsCalibrating = true;
-                IsStopCalibrate = false;
+            // 勾选 well:未传则默认 16 well;去重、按序、限定 1-16。
+            var wellList = (wells ?? Enumerable.Range(1, 16))
+                .Where(w => w >= 1 && w <= 16).Distinct().OrderBy(w => w).ToList();
+            if (wellList.Count == 0) wellList = Enumerable.Range(1, 16).ToList();
 
-                // M8-P3b:一键标定为关键命令入口,建立操作日志 scope(统一 traceId 串联本次标定内的 HTTP/串口/相机埋点)。
-                using var _opScope = Aivfo.OperationLog.OperationLogger.Begin("对焦调试", "一键标定",
-                    houseSn: CurrentHouseId);
-                try { _opScope.Input(new { houseSn = CurrentHouseId }); } catch { }
+            IsCalibrating = true;
+            IsStopCalibrate = false;
 
-                // 勾选 well:未传则默认 16 well;去重、按序、限定 1-16。
-                var wellList = (wells != null ? wells : Enumerable.Range(1, 16))
-                    .Where(w => w >= 1 && w <= 16).Distinct().OrderBy(w => w).ToList();
-                if (wellList.Count == 0) wellList = Enumerable.Range(1, 16).ToList();
+            try
+            {
+                // M8-P3b:一键标定发起为关键命令入口,记一条操作日志(start 同步发起;异步轮询单独不再串 scope,
+                // 避免 using scope 在方法返回即 Dispose 后被后台轮询误用)。
+                using (var _opScope = Aivfo.OperationLog.OperationLogger.Begin("对焦调试", "一键标定发起(协作)", houseSn: CurrentHouseId))
+                {
+                    try { _opScope.Input(new { houseSn = CurrentHouseId, wells = wellList.ToArray() }); } catch { }
 
-                // 合格判据阈值:tl_setting.focus_peak_ratio_threshold,缺省 1.2(03 §7.1)。
-                double peakThreshold = (tLSetting != null && tLSetting.focusPeakRatioThreshold.HasValue)
-                    ? (double)tLSetting.focusPeakRatioThreshold.Value : 1.2;
+                    ResetCalibResults(wellList);
 
-                ResetCalibResults(wellList);
+                    // D2-02-T3.4:标定由 control 协作端执行(scene=0 落库由 control 端处理)。
+                    // operate 仅发 start + 轮询进度刷新 16 格 UI,不再 operate 本地 new CalibrationEngine/CalibrationStore。
+                    var r = await _calibClient.StartAsync(wellList);
+                    if (!r.Ok)
+                    {
+                        IsCalibrating = false;
+                        AddMessageInfo($"[一键标定]发起失败:{r.Code}/{r.Error}");
+                        try { _opScope.Fail($"start 失败:{r.Code}/{r.Error}"); } catch { }
+                        return;
+                    }
+                    try { _opScope.Output(new { started = true }).Success(); } catch { }
+                }
+                AddMessageInfo($"[一键标定]已发起(control 协作):舱{CurrentHouse.houseSn} 共 {wellList.Count} well,开始轮询进度...");
 
-                // 标定结果存储:calibration.json 真相源(12 §2.7/03 §4,IncludeFields=true 由 CalibrationFile.Load 保证)。
-                // operate 端无 SqlSugar 直连(走 HttpHelper),故 DbMirror=null:仅写 JSON 真相源;
-                // house_autofocus_calibration 的 scene=0 基准 upsert 由 control 端 AppData.AutofocusStore 镜像(M2-04)。
-                var store = new CalibrationStore
-                {
-                    JsonPath = System.IO.Path.Combine(
-                        System.AppDomain.CurrentDomain.BaseDirectory, @"DependFile\AutoFocus\calibration.json"),
-                    Source = "OPERATE_DEBUG_SCENE0",
-                    Log = msg => AddMessageInfo($"[一键标定]{msg}"),
-                    // DbMirror 为空 → 只写 JSON 不镜像库(operate 无 DB 直连)。
-                };
-                try
+                // 起轮询任务,把 control 端进度映射到 16 格 UI;IsRunning 转 false 即停。
+                StartCalibPolling();
+            }
+            catch (Exception ex)
+            {
+                IsCalibrating = false;
+                ExLog(ex, "OneClickCalibrate");
+                AddMessageInfo($"[一键标定]整体异常:{ex.Message}");
+            }
+        }
+
+        /// <summary>
+        /// D2-02-T3.4 单孔重标:经 control 协作端对该 well 重新标定一次,并起轮询刷新该格。
+        /// </summary>
+        public async Task RecalibrateWell(int wellSn)
+        {
+            if (_calibClient == null || string.IsNullOrEmpty(CurrentSessionId))
+            {
+                AddMessageInfo("[重标]未建立调试会话,无法重标");
+                return;
+            }
+            if (IsCalibrating)
+            {
+                AddMessageInfo("[重标]批量标定进行中,请先中止或等待结束");
+                return;
+            }
+            if (wellSn < 1 || wellSn > 16)
+            {
+                AddMessageInfo("[重标]well 只能为 1-16");
+                return;
+            }
+            try
+            {
+                IsCalibrating = true;
+                IsStopCalibrate = false;
+                UpdateCalibResult(wellSn, item => { item.State = WellCalibState.Running; item.Note = "重标中..."; });
+                var r = await _calibClient.RecalibrateAsync(wellSn);
+                if (!r.Ok)
                 {
-                    var jsonDir = System.IO.Path.GetDirectoryName(store.JsonPath);
-                    if (!string.IsNullOrEmpty(jsonDir) && !System.IO.Directory.Exists(jsonDir))
-                        System.IO.Directory.CreateDirectory(jsonDir);
+                    IsCalibrating = false;
+                    AddMessageInfo($"[重标]well{wellSn} 发起失败:{r.Code}/{r.Error}");
+                    return;
                 }
-                catch (Exception exDir)
+                AddMessageInfo($"[重标]well{wellSn} 已发起,轮询进度...");
+                StartCalibPolling();
+            }
+            catch (Exception ex)
+            {
+                IsCalibrating = false;
+                ExLog(ex, "RecalibrateWell");
+                AddMessageInfo($"[重标]well{wellSn} 异常:{ex.Message}");
+            }
+        }
+
+        /// <summary>工程师中止一键标定:通知 control 停标,停本地轮询。</summary>
+        public void StopCalibrate()
+        {
+            IsStopCalibrate = true;
+            AddMessageInfo("[一键标定]收到中止请求,通知 control 停止...");
+            var calib = _calibClient;
+            if (calib != null)
+            {
+                Task.Run(async () =>
                 {
-                    AddMessageInfo($"[一键标定]创建 calibration.json 目录失败(将仅 UI 显示):{exDir.Message}");
-                }
+                    try { await calib.StopAsync(); } catch (Exception ex) { ExLog(ex, "StopCalibrate.StopAsync"); }
+                });
+            }
+            // 轮询任务读到 IsRunning=false 后会自停;此处也主动停一次兜底。
+            StopCalibPolling();
+        }
 
-                string tlSn = (tLSetting != null && !string.IsNullOrEmpty(tLSetting.tlSn))
-                    ? tLSetting.tlSn : (CurrentHouse.tlSn ?? AppData.Instance.TlSn);
+        /// <summary>
+        /// 起标定进度轮询(~800ms 一次):调 control /debug/calibrate/progress,
+        /// 把每孔 WellCalibProgressDto(State 数字枚举)映射到 16 格 WellCalibUiItem。
+        /// IsRunning 转 false(或会话失效返回 null)即停轮询并复位 IsCalibrating。
+        /// 同一时刻只跑一个轮询任务(先停旧的)。
+        /// </summary>
+        private void StartCalibPolling()
+        {
+            StopCalibPolling();
+            var cts = new System.Threading.CancellationTokenSource();
+            _calibPollCts = cts;
+            var token = cts.Token;
+            var calib = _calibClient;
 
+            Task.Run(async () =>
+            {
                 try
                 {
-                    // M1-B2:借用到的 lease.Serial/lease.Camera 本就是 HAL 接口(ISerialChannel/ICamera),
-                    // 直接喂给 M2-01 引擎,无需再经 DebugSerial/CameraAdapter 包 operate 具体类型(已退役)。
-                    var serial = Serial;
-                    var cam = Cam;
-                    var engine = new CalibrationEngine(serial, cam)
+                    while (!token.IsCancellationRequested)
                     {
-                        Log = msg => AddMessageInfo(msg)
-                    };
-
-                    AddMessageInfo($"[一键标定]开始:舱{CurrentHouse.houseSn} 共 {wellList.Count} well,合格峰比阈值={peakThreshold:F2}");
+                        var dto = await calib.PollProgressAsync();
+                        if (token.IsCancellationRequested) break;
 
-                    foreach (var well in wellList)
-                    {
-                        if (IsStopCalibrate)
+                        if (dto == null)
                         {
-                            AddMessageInfo("[一键标定]已被中止,停止后续 well");
+                            // 会话失效/无进度:停轮询(会话失效另由 DebugSessionClient.OnSessionExpired 提示)。
+                            AddMessageInfo("[一键标定]进度查询无结果(会话可能已失效),停止轮询");
                             break;
                         }
-                        UpdateCalibResult(well, item => { item.State = WellCalibState.Running; item.Note = "标定中..."; });
-                        CurrentWell = well;
-                        try
-                        {
-                            // EEPROM 仅作扫描中心(参考),不进配置解析链、不回写(§2.5)。
-                            int hpos = serial.ReadWellHorizontalPosWait(well);
-                            int zZero = serial.ReadWellFocusZeroWait(well);
-                            var wc = engine.CalibrateWell(well, Math.Max(0, hpos), Math.Max(0, zZero));
 
-                            if (wc == null)
-                            {
-                                UpdateCalibResult(well, item =>
-                                {
-                                    item.State = WellCalibState.Failed;
-                                    item.Note = "无结果";
-                                });
-                                AddMessageInfo($"[一键标定]{well}号 well 无标定结果");
-                                continue;
-                            }
+                        MapProgressToUi(dto);
 
-                            // 合格判据:circleFound && peakRatio>阈值 → 绿;否则(未检圆/伪峰/弱峰) → 红。
-                            bool qualified = wc.CircleFound && wc.PeakRatio > peakThreshold;
-                            UpdateCalibResult(well, item =>
-                            {
-                                item.State = qualified ? WellCalibState.Qualified : WellCalibState.FakePeak;
-                                item.FocusZ = wc.FocusZ;
-                                item.PeakRatio = wc.PeakRatio;
-                                item.CenterOffsetPct = wc.CenterOffsetPct;
-                                item.CircleFound = wc.CircleFound;
-                                item.Exposure = wc.Exposure;
-                                item.Note = qualified
-                                    ? $"合格 Z={wc.FocusZ} 峰比={wc.PeakRatio:F2}"
-                                    : $"伪峰 Z={wc.FocusZ} 峰比={wc.PeakRatio:F2} {(wc.CircleFound ? "" : "未检圆")}{wc.Note}";
-                            });
-                            AddMessageInfo($"[一键标定]{well}号 well {(qualified ? "✓合格(绿)" : "✗伪峰(红)")} " +
-                                           $"FocusZ={wc.FocusZ} 峰比={wc.PeakRatio:F2} 偏移={wc.CenterOffsetPct:F1}%");
-
-                            // 出厂基准 scene=0 落库 + 写 calibration.json(存储失败不崩 UI,CalibrationStore 内部已吞异常)。
-                            try
-                            {
-                                store.SaveCalibration(wc, tlSn, CurrentHouse.houseSn, well, scene: 0,
-                                    port: CurrentHouse.housePort, ccdSn: CurrentHouse.ccdSn);
-                            }
-                            catch (Exception exStore)
-                            {
-                                AddMessageInfo($"[一键标定]{well}号 well 结果存档失败:{exStore.Message}");
-                            }
-                        }
-                        catch (Exception exWell)
+                        if (!dto.IsRunning)
                         {
-                            // 单 well 异常不崩 UI:记日志/标红,继续下一 well。
-                            UpdateCalibResult(well, item =>
-                            {
-                                item.State = WellCalibState.Failed;
-                                item.Note = $"异常:{exWell.Message}";
-                            });
-                            ExLog(exWell, $"OneClickCalibrate.well{well}");
-                            AddMessageInfo($"[一键标定]{well}号 well 标定异常:{exWell.Message}");
+                            int okCount = CalibResults.Count(x => x.State == WellCalibState.Qualified);
+                            AddMessageInfo($"[一键标定]结束:合格 {okCount}/{dto.Total}(scene=0 基准由 control 协作端落库)");
+                            break;
                         }
+                        await Task.Delay(800, token);
                     }
-
-                    int okCount = CalibResults.Count(x => x.State == WellCalibState.Qualified);
-                    AddMessageInfo($"[一键标定]结束:合格 {okCount}/{wellList.Count}(结果已存档为出厂基准 scene=0 + calibration.json)");
-                    // M8-P3b:记录标定结果到操作日志 scope。
-                    try { _opScope.Output(new { okCount, total = wellList.Count }).Success(); } catch { }
                 }
+                catch (TaskCanceledException) { /* 主动停 */ }
                 catch (Exception ex)
                 {
-                    ExLog(ex, "OneClickCalibrate");
-                    AddMessageInfo($"[一键标定]整体异常:{ex.Message}");
-                    // M8-P3b:标定整体异常标记失败。
-                    try { _opScope.Fail(ex.GetType().Name + ": " + ex.Message); } catch { }
+                    ExLog(ex, "CalibPolling");
+                    AddMessageInfo($"[一键标定]轮询异常:{ex.Message}");
                 }
                 finally
                 {
                     IsCalibrating = false;
                 }
-            });
+            }, token);
         }
 
-        /// <summary>工程师中止一键标定。</summary>
-        public void StopCalibrate()
+        /// <summary>停标定进度轮询(取消轮询任务)。幂等。</summary>
+        private void StopCalibPolling()
         {
-            IsStopCalibrate = true;
-            AddMessageInfo("[一键标定]收到中止请求,将在当前 well 结束后停止");
+            var cts = _calibPollCts;
+            _calibPollCts = null;
+            if (cts != null) { try { cts.Cancel(); } catch { } try { cts.Dispose(); } catch { } }
         }
 
+        /// <summary>把 control 进度 DTO 的每孔状态映射到 16 格 UI(State 为数字枚举:0待标/1标定中/2合格/3伪峰/4失败)。</summary>
+        private void MapProgressToUi(CalibProgressDto dto)
+        {
+            if (dto?.Wells == null) return;
+            foreach (var w in dto.Wells)
+            {
+                var well = w;   // 闭包捕获
+                UpdateCalibResult(well.WellSn, item =>
+                {
+                    item.State = (WellCalibState)well.State;   // 0..4 与 operate 端枚举顺序一致
+                    item.FocusZ = well.FocusZ;
+                    item.Exposure = well.Exposure;
+                    item.PeakRatio = well.PeakRatio;
+                    item.CenterOffsetPct = well.CenterOffsetPct;
+                    item.CircleFound = well.CircleFound;
+                    item.Note = string.IsNullOrEmpty(well.Note)
+                        ? StateNote((WellCalibState)well.State, well.FocusZ, well.PeakRatio)
+                        : well.Note;
+                });
+            }
+        }
+
+        private static string StateNote(WellCalibState st, int focusZ, double peakRatio) => st switch
+        {
+            WellCalibState.Pending => "待标定",
+            WellCalibState.Running => "标定中...",
+            WellCalibState.Qualified => $"合格 Z={focusZ} 峰比={peakRatio:F2}",
+            WellCalibState.FakePeak => $"伪峰 Z={focusZ} 峰比={peakRatio:F2}",
+            WellCalibState.Failed => "失败",
+            _ => ""
+        };
+
         private void ResetCalibResults(List<int> wells)
         {
             void build()
@@ -1591,5 +1732,108 @@ namespace ivf_tl_Operate.ViewModel
         }
 
         #endregion
+
+        #region D2-02-T3.5 保存 per-well 对焦搜索范围(半幅)
+
+        /// <summary>
+        /// 当前编辑的 well 级"水平搜索半幅"(围绕 horizontalMotorPosition)。空字符串=留空继承设备级(保存写 null)。
+        /// View TextBox 双向绑定;校验在保存时做(非空须为 ≥0 整数)。
+        /// </summary>
+        [ObservableProperty] private string currentHFocusRange = "";
+
+        /// <summary>
+        /// 当前编辑的 well 级"垂直搜索半幅"(围绕 eepromClearPosition/清晰位)。空=继承设备级(写 null)。校验:≥0。
+        /// </summary>
+        [ObservableProperty] private string currentVFocusRange = "";
+
+        /// <summary>
+        /// 取该 well 当前 well 级半幅覆盖值回填两个输入框(空=继承设备级,显示空占位)。供"载入半幅"按钮调用。
+        /// 复用 ManualTuneWell 作为锚点 well(与手调拍摄层同一选定 well)。
+        /// </summary>
+        public void LoadWellFocusRange(int well)
+        {
+            ManualTuneWell = well;
+            var ws = houseWellSettingList.FirstOrDefault(x => x.houseSn == CurrentHouseId && x.wellSn == well);
+            CurrentHFocusRange = ws?.horizontalFocusRange?.ToString() ?? "";
+            CurrentVFocusRange = ws?.verticalFocusRange?.ToString() ?? "";
+            AddMessageInfo($"[对焦半幅]已载入 well{well} 当前覆盖:水平={(ws?.horizontalFocusRange?.ToString() ?? "继承")} 垂直={(ws?.verticalFocusRange?.ToString() ?? "继承")}");
+        }
+
+        /// <summary>
+        /// D2-02-T3.5 保存该 well 对焦搜索半幅:校验通过后写 house_well_setting well 级覆盖
+        /// (horizontal_focus_range/vertical_focus_range)。留空字段=继承设备级(写 null)。
+        /// 复用现有 well 设置保存通道 HttpHelper.WellUpdateApi(同 SetWellMotorPosition/SaveManualLayerTune),
+        /// 走 /well/update;后端 well/update 已支持这两列(Java HouseWellSettingUpdate.horizontalFocusRange/verticalFocusRange)。
+        /// 字段名与 Java DTO 完全一致:horizontalFocusRange / verticalFocusRange。
+        /// 返回 true=保存成功。
+        /// </summary>
+        public bool SaveWellFocusRange()
+        {
+            try
+            {
+                if (CurrentHouse == null)
+                {
+                    AddMessageInfo("[对焦半幅]未获取到舱室信息");
+                    return false;
+                }
+                if (ManualTuneWell < 1 || ManualTuneWell > 16)
+                {
+                    AddMessageInfo("[对焦半幅]well 只能为 1-16");
+                    return false;
+                }
+
+                // 校验:非空须为 ≥0 整数;留空=继承设备级(写 null)。
+                int? hRange;
+                if (string.IsNullOrWhiteSpace(CurrentHFocusRange)) hRange = null;
+                else if (int.TryParse(CurrentHFocusRange.Trim(), out int hv) && hv >= 0) hRange = hv;
+                else { AddMessageInfo("[对焦半幅]水平半幅只能输入 ≥0 整数(留空=继承设备级)"); return false; }
+
+                int? vRange;
+                if (string.IsNullOrWhiteSpace(CurrentVFocusRange)) vRange = null;
+                else if (int.TryParse(CurrentVFocusRange.Trim(), out int vv) && vv >= 0) vRange = vv;
+                else { AddMessageInfo("[对焦半幅]垂直半幅只能输入 ≥0 整数(留空=继承设备级)"); return false; }
+
+                // M8 埋点:保存对焦半幅为关键业务操作(统一 traceId 串其内 WellUpdateApi HTTP 埋点)。
+                using var _op = Aivfo.OperationLog.OperationLogger.Begin("对焦调试", "保存对焦搜索半幅",
+                    houseSn: CurrentHouseId);
+                try { _op.Input(new { houseSn = CurrentHouseId, well = ManualTuneWell, horizontalFocusRange = hRange, verticalFocusRange = vRange }); } catch { }
+
+                // body 字段名与 Java HouseWellSettingUpdate 完全一致;留空=null 清空继承设备级(与 SaveManualLayerTune 一致)。
+                string body = JsonConvert.SerializeObject(new
+                {
+                    tlSn = CurrentHouse.tlSn,
+                    houseSn = CurrentHouse.houseSn,
+                    wellSn = ManualTuneWell,
+                    horizontalFocusRange = hRange,   // null=继承设备级 focus_h_range_default
+                    verticalFocusRange = vRange,     // null=继承设备级 focus_v_range_default
+                });
+                bool ok = AppData.Instance.HttpHelper.WellUpdateApi(body);
+                if (!ok)
+                {
+                    AddMessageInfo("[对焦半幅]保存失败(接口返回失败)");
+                    try { _op.Fail("WellUpdateApi 返回失败"); } catch { }
+                    return false;
+                }
+
+                // 本地内存同步,使取数一致(不重载设置即生效)。
+                var ws = houseWellSettingList.FirstOrDefault(x => x.houseSn == CurrentHouseId && x.wellSn == ManualTuneWell);
+                if (ws != null)
+                {
+                    ws.horizontalFocusRange = hRange;
+                    ws.verticalFocusRange = vRange;
+                }
+                AddMessageInfo($"[对焦半幅]well{ManualTuneWell} 已保存:水平={(hRange?.ToString() ?? "继承")} 垂直={(vRange?.ToString() ?? "继承")}(写 house_well_setting well 级覆盖,留空继承设备级)");
+                try { _op.Output(new { ok = true }).Success(); } catch { }
+                return true;
+            }
+            catch (Exception ex)
+            {
+                ExLog(ex, "SaveWellFocusRange");
+                AddMessageInfo($"[对焦半幅]保存异常:{ex.Message}");
+                return false;
+            }
+        }
+
+        #endregion
     }
 }