darra-ethercat-master 2.0.7

商业 EtherCAT 主站协议栈 · 实时内核驱动 · 抖动 1µs · Windows + Linux · 多编程语言 · 全协议 · 支持复杂拓扑 + 热插拔 · ethercat.darra.xyz · Commercial EtherCAT Master protocol stack · Real-time kernel driver · 1µs jitter · Multi-platform · Multi-language · Complex topology + hot-plug.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
//! FoE (File over EtherCAT) 实例
//!
//! 对应 C# Slave/FoE.cs
//! 提供文件上传/下载操作。
//!
//! # 异步使用 (对齐 C# FoEInstance.DownloadAsync / UploadAsync)
//!
//! FoE 固件升级常耗时数分钟, 强烈建议在异步上下文中执行以避免阻塞其它协议。
//! 本 crate 提供**双轨**异步 API:
//!
//! ## 轨道 1: std::thread (无依赖, 默认启用)
//!
//! ```ignore
//! use ethercat::slave::foe::FoEInstance;
//!
//! let handle = FoEInstance::download_blocking(0, 1, "firmware.bin".into(), None, Some(60_000));
//! let firmware = handle.join().unwrap()?;
//!
//! let handle = FoEInstance::upload_blocking(0, 1, "firmware.bin".into(), vec![/* data */], None, Some(60_000));
//! handle.join().unwrap()?;
//! ```
//!
//! ## 轨道 2: tokio async (可选 feature `async-tokio`)
//!
//! ```ignore
//! let foe = slave.foe();
//! let firmware = foe.download_async("firmware.bin".into(), None, Some(60_000)).await?;
//! foe.upload_async("firmware.bin".into(), firmware_bytes, None, Some(60_000)).await?;
//!
//! // tokio timeout 包装
//! let result = tokio::time::timeout(
//!     std::time::Duration::from_secs(120),
//!     foe.upload_async("firmware.bin".into(), bytes, None, Some(60_000))
//! ).await??;
//! ```
//!
//! ## 注意事项
//!
//! - 多个从站固件**并行**升级: 在 tokio 中用 `JoinSet` 管理多个 spawn_blocking task
//! - 进度回调 (`FoEProgressHook`) 跨线程安全, 但回调在 DLL 线程触发,
//!   若需推送到 async 通道, 使用 `tokio::sync::mpsc` 或 `std::sync::mpsc`
//! - 一个 tokio runtime 默认 blocking pool 有 512 线程上限, 足够固件升级用量

use crate::utils::ffi;
use crate::data::error::{DarraError, FoEErrorCode, Result};
use std::ffi::{CStr, CString};
use std::sync::{Arc, Mutex, OnceLock};

/// FoE 实例
///
/// 对应 C# FoEInstance
pub struct FoEInstance {
    master_index: u16,
    slave_index: u16,
    /// 默认超时时间(毫秒)
    pub default_timeout_ms: i32,
    /// 默认密码
    pub default_password: u32,
    /// 最近一次 FoE 错误码
    last_error: Option<FoEErrorCode>,
}

impl FoEInstance {
    /// 创建新的 FoE 实例
    pub fn new(master_index: u16, slave_index: u16) -> Self {
        Self {
            master_index,
            slave_index,
            default_timeout_ms: 5000,
            default_password: 0,
            last_error: None,
        }
    }

    /// 获取最近一次 FoE 错误码
    pub fn last_error(&self) -> Option<FoEErrorCode> {
        self.last_error
    }

    /// 从站是否支持 FoE 邮箱协议 (对齐 C# FoEInstance.IsSupported).
    ///
    /// 读取 SII mbx_proto bit 3 (ECT_MBXPROT_FOE = 0x08) 判断支持情况.
    /// DLL 调用失败时保守返回 true (未知能力视为支持).
    pub fn is_supported(&self) -> bool {
        let proto = unsafe { ffi::GetSlaveMailboxProto(self.master_index, self.slave_index) };
        (proto & 0x08) != 0
    }

    /// 从从站设备下载(读取)文件
    ///
    /// 对应 C# FoEInstance.Download
    pub fn download(
        &self,
        filename: &str,
        password: Option<u32>,
        timeout_ms: Option<i32>,
    ) -> Result<Vec<u8>> {
        let c_filename = CString::new(filename)
            .map_err(|_| DarraError::InvalidParameter("文件名包含空字节".into()))?;

        let pwd = password.unwrap_or(self.default_password);
        let timeout_us = (timeout_ms.unwrap_or(self.default_timeout_ms)) * 1000;

        let mut data_ptr = std::ptr::null_mut();
        let mut file_size: i32 = 0;

        let result = unsafe {
            ffi::FOERead(
                self.master_index,
                self.slave_index,
                c_filename.as_ptr(),
                pwd,
                &mut data_ptr,
                &mut file_size,
                timeout_us,
            )
        };

        if result == 0 || data_ptr.is_null() || file_size <= 0 {
            if !data_ptr.is_null() {
                unsafe { ffi::FreeMemory(data_ptr as *mut std::os::raw::c_void); }
            }
            return Err(DarraError::FoeFailed(format!(
                "FoE 下载失败: 从站={}, 文件={}",
                self.slave_index, filename
            )));
        }

        let data = unsafe {
            let slice = std::slice::from_raw_parts(data_ptr as *const u8, file_size as usize);
            let vec = slice.to_vec();
            ffi::FreeMemory(data_ptr as *mut std::os::raw::c_void);
            vec
        };

        Ok(data)
    }

    /// 上传(写入)文件到从站设备
    ///
    /// 对应 C# FoEInstance.Upload
    pub fn upload(
        &self,
        filename: &str,
        file_data: &[u8],
        password: Option<u32>,
        timeout_ms: Option<i32>,
    ) -> Result<()> {
        let c_filename = CString::new(filename)
            .map_err(|_| DarraError::InvalidParameter("文件名包含空字节".into()))?;

        if file_data.is_empty() {
            return Err(DarraError::InvalidParameter("文件数据不能为空".into()));
        }

        let pwd = password.unwrap_or(self.default_password);
        let timeout_us = (timeout_ms.unwrap_or(self.default_timeout_ms)) * 1000;

        let result = unsafe {
            ffi::FOEWrite(
                self.master_index,
                self.slave_index,
                c_filename.as_ptr(),
                pwd,
                file_data.as_ptr() as *const std::os::raw::c_void,
                file_data.len() as i32,
                timeout_us,
            )
        };

        if result == 0 {
            return Err(DarraError::FoeFailed(format!(
                "FoE 上传失败: 从站={}, 文件={}",
                self.slave_index, filename
            )));
        }

        Ok(())
    }

    /// 设置 FoE 进度回调
    pub fn set_progress_hook(&self, callback: ffi::FoEProgressCallback) -> bool {
        unsafe { ffi::FOESetProgressHook(self.master_index, Some(callback)) != 0 }
    }

    /// 清除 FoE 进度回调
    pub fn clear_progress_hook(&self) -> bool {
        unsafe { ffi::FOEClearProgressHook(self.master_index) != 0 }
    }

    // ===================== FoE 取消 / BUSY 回调 (对齐 C# FoE.cs) =====================

    /// 请求取消当前 FoE 传输 (对齐 C# `DLL.FOERequestCancel`).
    ///
    /// 透传 ec_foe.c 的取消标志, 下一次传输循环迭代起生效. 固件烧写中途取消
    /// 可能导致从站 Flash 部分写入, 需重新上传完整固件.
    ///
    /// 返回: true=DLL 已接受取消请求.
    pub fn cancel(&self) -> bool {
        unsafe { ffi::FOERequestCancel(self.master_index, self.slave_index) != 0 }
    }

    /// 清除 FoE 取消标志 (对齐 C# `DLL.FOEClearCancel`).
    ///
    /// Read / Write 入口会自动清零, 仅异常复位用.
    pub fn clear_cancel(&self) -> bool {
        unsafe { ffi::FOEClearCancel(self.master_index, self.slave_index) != 0 }
    }

    /// 设置 BUSY 回调 (ETG.1000.6 Table 93 Done/Entire/BusyText).
    ///
    /// 从站 Flash 烧写慢时周期返回 BUSY 帧, 携带已完成量 `done` / 总量 `entire` /
    /// 可选文本 `text`. 对齐 C# `FoEInstance.EnableProgressHook` 中 `FOESetBusyHook`.
    ///
    /// # 设计
    /// 由于 DLL 层按主站索引注册**单个** C FFI 回调, Rust 端用全局
    /// `OnceLock<Arc<Mutex<Vec<FoEBusyCallback>>>>` 维护回调列表, C trampoline
    /// 快照后遍历调用, 支持多订阅. 对齐 Z-4 `FsoeDataExchangeDispatcher` 模式.
    pub fn set_busy_hook(&self, cb: FoEBusyCallback) {
        add_busy_hook(self.master_index, cb);
    }

    /// 清空本主站所有 BUSY 回调, 并通知 DLL 解绑.
    pub fn clear_busy_hooks(&self) {
        clear_busy_hooks(self.master_index);
    }

    /// 估算文件传输所需的数据包数量
    pub fn estimate_packet_count(file_size: usize, mailbox_size: usize) -> usize {
        if file_size == 0 || mailbox_size == 0 {
            return 0;
        }
        let data_per_packet = if mailbox_size > 6 { mailbox_size - 6 } else { 128 };
        (file_size + data_per_packet - 1) / data_per_packet
    }

    // ===================== 默认超时/密码属性 =====================

    /// 获取默认超时时间 (毫秒)
    pub fn default_timeout_ms(&self) -> i32 {
        self.default_timeout_ms
    }

    /// 设置默认超时时间 (毫秒)
    pub fn set_default_timeout_ms(&mut self, ms: i32) {
        self.default_timeout_ms = ms;
    }

    /// 获取默认密码
    pub fn default_password(&self) -> u32 {
        self.default_password
    }

    /// 设置默认密码
    pub fn set_default_password(&mut self, pw: u32) {
        self.default_password = pw;
    }

    // ===================== CRC 增强传输 =====================

    /// 从从站设备下载(读取)文件,启用 CRC 校验
    ///
    /// 对应 C# FoEInstance.DownloadWithCRC / FOEReadEx
    pub fn download_with_crc(
        &mut self,
        filename: &str,
        password: Option<u32>,
        timeout_ms: Option<i32>,
        enable_crc: bool,
    ) -> Result<Vec<u8>> {
        let c_filename = CString::new(filename)
            .map_err(|_| DarraError::InvalidParameter("文件名包含空字节".into()))?;

        let pwd = password.unwrap_or(self.default_password);
        let timeout_us = (timeout_ms.unwrap_or(self.default_timeout_ms)) * 1000;

        let mut data_ptr = std::ptr::null_mut();
        let mut file_size: i32 = 0;

        // 构建 FoE 扩展选项
        // strict_mode: CRC 启用时设为 1 (对齐 C# FoEInstance.DownloadWithCRC)
        let mut options = ffi::FoEOptions {
            enable_crc: if enable_crc { 1 } else { 0 },
            strict_mode: if enable_crc { 1 } else { 0 },
            auto_append_crc: if enable_crc { 1 } else { 0 },
            expected_crc: 0,
            crc_progress_callback: None,
            crc_callback_userdata: std::ptr::null_mut(),
            reserved: [0u32; 8],
        };

        let result = unsafe {
            ffi::FOEReadEx(
                self.master_index,
                self.slave_index,
                c_filename.as_ptr(),
                pwd,
                &mut data_ptr,
                &mut file_size,
                timeout_us,
                &mut options,
            )
        };

        if result == 0 || data_ptr.is_null() || file_size <= 0 {
            if !data_ptr.is_null() {
                unsafe { ffi::FreeMemory(data_ptr as *mut std::os::raw::c_void); }
            }
            self.last_error = Some(FoEErrorCode::NotDefined);
            return Err(DarraError::FoeFailed(format!(
                "FoE CRC下载失败: 从站={}, 文件={}",
                self.slave_index, filename
            )));
        }

        let data = unsafe {
            let slice = std::slice::from_raw_parts(data_ptr as *const u8, file_size as usize);
            let vec = slice.to_vec();
            ffi::FreeMemory(data_ptr as *mut std::os::raw::c_void);
            vec
        };

        self.last_error = None;
        Ok(data)
    }

    /// 上传(写入)文件到从站设备,启用 CRC 校验
    ///
    /// 对应 C# FoEInstance.UploadWithCRC / FOEWriteEx
    pub fn upload_with_crc(
        &mut self,
        filename: &str,
        file_data: &[u8],
        password: Option<u32>,
        timeout_ms: Option<i32>,
        enable_crc: bool,
    ) -> Result<()> {
        let c_filename = CString::new(filename)
            .map_err(|_| DarraError::InvalidParameter("文件名包含空字节".into()))?;

        if file_data.is_empty() {
            return Err(DarraError::InvalidParameter("文件数据不能为空".into()));
        }

        let pwd = password.unwrap_or(self.default_password);
        let timeout_us = (timeout_ms.unwrap_or(self.default_timeout_ms)) * 1000;

        // 构建 FoE 扩展选项
        // strict_mode: CRC 启用时设为 1 (对齐 C# FoEInstance.UploadWithCRC)
        let mut options = ffi::FoEOptions {
            enable_crc: if enable_crc { 1 } else { 0 },
            strict_mode: if enable_crc { 1 } else { 0 },
            auto_append_crc: if enable_crc { 1 } else { 0 },
            expected_crc: 0,
            crc_progress_callback: None,
            crc_callback_userdata: std::ptr::null_mut(),
            reserved: [0u32; 8],
        };

        let result = unsafe {
            ffi::FOEWriteEx(
                self.master_index,
                self.slave_index,
                c_filename.as_ptr(),
                pwd,
                file_data.as_ptr() as *const std::os::raw::c_void,
                file_data.len() as i32,
                timeout_us,
                &mut options,
            )
        };

        if result == 0 {
            self.last_error = Some(FoEErrorCode::NotDefined);
            return Err(DarraError::FoeFailed(format!(
                "FoE CRC上传失败: 从站={}, 文件={}",
                self.slave_index, filename
            )));
        }

        self.last_error = None;
        Ok(())
    }

    /// 获取 FoE 错误代码的中文描述文本 (对齐 C# GetErrorDescription)
    pub fn get_error_description(error_code: FoEErrorCode) -> &'static str {
        match error_code {
            FoEErrorCode::NotDefined       => "未定义错误",
            FoEErrorCode::NotFound         => "文件未找到",
            FoEErrorCode::AccessDenied     => "访问被拒绝",
            FoEErrorCode::DiskFull         => "磁盘已满",
            FoEErrorCode::Illegal          => "非法操作",
            FoEErrorCode::PacketNumberWrong => "数据包序号错误",
            FoEErrorCode::AlreadyExists    => "文件已存在",
            FoEErrorCode::NoUser           => "无此用户",
            FoEErrorCode::BootstrapOnly    => "仅 Bootstrap 模式可用",
            FoEErrorCode::NotBootstrap     => "不在 Bootstrap 模式",
            FoEErrorCode::NoRights         => "权限不足",
            FoEErrorCode::ProgramError     => "程序错误",
        }
    }
}

// ===================== 异步 API (轨道 1: std::thread, 无依赖) =====================

impl FoEInstance {
    /// FoE 下载 (从从站读取文件, std::thread 异步包装)
    ///
    /// 返回 `JoinHandle`, 用户通过 `.join()` 等待结果。
    pub fn download_blocking(
        master_index: u16,
        slave_index: u16,
        filename: String,
        password: Option<u32>,
        timeout_ms: Option<i32>,
    ) -> std::thread::JoinHandle<Result<Vec<u8>>> {
        std::thread::spawn(move || {
            let foe = FoEInstance::new(master_index, slave_index);
            foe.download(&filename, password, timeout_ms)
        })
    }

    /// FoE 上传 (写入文件到从站, std::thread 异步包装)
    pub fn upload_blocking(
        master_index: u16,
        slave_index: u16,
        filename: String,
        file_data: Vec<u8>,
        password: Option<u32>,
        timeout_ms: Option<i32>,
    ) -> std::thread::JoinHandle<Result<()>> {
        std::thread::spawn(move || {
            let foe = FoEInstance::new(master_index, slave_index);
            foe.upload(&filename, &file_data, password, timeout_ms)
        })
    }
}

// ===================== 异步 API (轨道 2: tokio, feature 门控) =====================

#[cfg(feature = "async-tokio")]
impl FoEInstance {
    /// FoE 下载 (tokio async, 使用 spawn_blocking)
    pub async fn download_async(
        &self,
        filename: String,
        password: Option<u32>,
        timeout_ms: Option<i32>,
    ) -> Result<Vec<u8>> {
        let master = self.master_index;
        let slave = self.slave_index;
        let default_pwd = self.default_password;
        let default_to = self.default_timeout_ms;
        tokio::task::spawn_blocking(move || {
            let mut foe = FoEInstance::new(master, slave);
            foe.default_password = default_pwd;
            foe.default_timeout_ms = default_to;
            foe.download(&filename, password, timeout_ms)
        })
        .await
        .map_err(|e| DarraError::Other(format!("tokio join error: {}", e)))?
    }

    /// FoE 上传 (tokio async, 使用 spawn_blocking)
    pub async fn upload_async(
        &self,
        filename: String,
        file_data: Vec<u8>,
        password: Option<u32>,
        timeout_ms: Option<i32>,
    ) -> Result<()> {
        let master = self.master_index;
        let slave = self.slave_index;
        let default_pwd = self.default_password;
        let default_to = self.default_timeout_ms;
        tokio::task::spawn_blocking(move || {
            let mut foe = FoEInstance::new(master, slave);
            foe.default_password = default_pwd;
            foe.default_timeout_ms = default_to;
            foe.upload(&filename, &file_data, password, timeout_ms)
        })
        .await
        .map_err(|e| DarraError::Other(format!("tokio join error: {}", e)))?
    }
}

// ===================== IMailboxProtocol trait impl (对齐 C# FoEInstance) =====================

impl crate::abstractions::MailboxProtocol for FoEInstance {
    fn protocol_type(&self) -> u8 { 0x04 }
    fn protocol_name(&self) -> &'static str { "FoE" }

    fn is_supported(&self) -> bool {
        FoEInstance::is_supported(self)
    }

    fn last_error_code(&self) -> u32 {
        match self.last_error {
            Some(e) => e as u32,
            None => 0,
        }
    }

    fn statistics(&self) -> crate::abstractions::MailboxStatistics {
        let mut stats = ffi::EcMbxStatsC::default();
        let rc = unsafe {
            ffi::mbx_get_stats_by_master(
                self.master_index, self.slave_index, 0x04, &mut stats,
            )
        };
        if rc == 1 {
            stats.into()
        } else {
            crate::abstractions::MailboxStatistics::empty()
        }
    }

    fn reset_statistics(&self) {
        unsafe {
            ffi::mbx_reset_stats_by_master(self.master_index, self.slave_index, 0x04);
        }
    }
}

// ===================== FoE BUSY 事件分发 (对齐 C# FoEProgressEventArgs.Busy*) =====================

/// FoE BUSY 事件 (ETG.1000.6 Table 93).
///
/// 从站 Flash 烧写过程中周期回传的"请继续等待"帧, 携带当前进度信息.
#[derive(Debug, Clone)]
pub struct FoEBusyEvent {
    /// 从站索引
    pub slave_index: u16,
    /// 已完成量 (Done 字段)
    pub done: u16,
    /// 总量 (Entire 字段)
    pub entire: u16,
    /// 可选文本 (BusyText 字段, 无文本时为空串)
    pub text: String,
    /// 当前 BUSY 重试计数
    pub retry_idx: i32,
    /// 估算百分比 (0-100, entire=0 时为 0)
    pub percent: u32,
}

impl FoEBusyEvent {
    fn new(slave: u16, done: u16, entire: u16, text: String, retry_idx: i32) -> Self {
        let percent = if entire > 0 {
            (((done as u64) * 100) / entire as u64).min(100) as u32
        } else {
            0
        };
        Self {
            slave_index: slave,
            done,
            entire,
            text,
            retry_idx,
            percent,
        }
    }
}

/// FoE BUSY 回调类型 (对齐任务清单 `set_busy_hook` 签名).
pub type FoEBusyCallback = Arc<dyn Fn(&FoEBusyEvent) + Send + Sync>;

/// 每主站独立的回调队列. key=master_index, value=回调列表.
fn busy_registry() -> &'static Mutex<std::collections::HashMap<u16, Vec<FoEBusyCallback>>> {
    static REG: OnceLock<Mutex<std::collections::HashMap<u16, Vec<FoEBusyCallback>>>> =
        OnceLock::new();
    REG.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}

/// 当前已注册到 DLL 的主站集合 (用于 clear 时是否要回调 `FOESetBusyHook(None)`).
fn busy_bound_masters() -> &'static Mutex<std::collections::HashSet<u16>> {
    static BOUND: OnceLock<Mutex<std::collections::HashSet<u16>>> = OnceLock::new();
    BOUND.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
}

/// C FFI trampoline — DLL 通过本函数把 BUSY 帧回传到 Rust 侧,
/// 快照当前主站的 Rust 回调列表后依次调用. `panic!` 被 `catch_unwind` 吞掉,
/// 保证不会从 FFI 边界抛出.
extern "C" fn busy_trampoline(
    slave: u16,
    done: u16,
    entire: u16,
    text: *const std::os::raw::c_char,
    retry_idx: std::os::raw::c_int,
) {
    let text_str = if text.is_null() {
        String::new()
    } else {
        unsafe { CStr::from_ptr(text) }
            .to_string_lossy()
            .into_owned()
    };

    // 注意: trampoline 无法知道 master_index (DLL C FFI 没传这个参数),
    // 所以我们把所有已注册主站的回调都调一遍 — 每个回调里用户自行根据
    // slave_index 过滤. 这与 C# `FoEProgressCallback` 的形态一致.
    let snapshots: Vec<(u16, Vec<FoEBusyCallback>)> = match busy_registry().lock() {
        Ok(g) => g.iter().map(|(k, v)| (*k, v.clone())).collect(),
        Err(_) => return,
    };

    if snapshots.is_empty() {
        return;
    }

    for (_master, cbs) in &snapshots {
        for cb in cbs {
            let ev = FoEBusyEvent::new(slave, done, entire, text_str.clone(), retry_idx as i32);
            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cb(&ev)));
        }
    }
}

/// 注册一个 BUSY 回调到指定主站. 首次注册时调用 `FOESetBusyHook` 绑定 DLL.
fn add_busy_hook(master_index: u16, cb: FoEBusyCallback) {
    if let Ok(mut reg) = busy_registry().lock() {
        reg.entry(master_index).or_default().push(cb);
    }

    // 若本主站尚未绑定 DLL, 绑定一次
    let need_bind = match busy_bound_masters().lock() {
        Ok(mut set) => set.insert(master_index),
        Err(_) => false,
    };
    if need_bind {
        unsafe {
            let _ = ffi::FOESetBusyHook(master_index, Some(busy_trampoline));
        }
    }
}

/// 清空指定主站所有 BUSY 回调并通知 DLL 解绑.
fn clear_busy_hooks(master_index: u16) {
    if let Ok(mut reg) = busy_registry().lock() {
        reg.remove(&master_index);
    }
    let was_bound = match busy_bound_masters().lock() {
        Ok(mut set) => set.remove(&master_index),
        Err(_) => false,
    };
    if was_bound {
        unsafe {
            let _ = ffi::FOESetBusyHook(master_index, None);
        }
    }
}