Files
DriverLIdar/README.md
QUYVN 59880871b0 Fix Family B angle decode + add LR-1FMI model
parse_family_b() dùng sai hệ số góc 0.25°/LSB; theo spec Olei chính hãng
(Olei.LidarSensor/LidarDataBlock.GetAngleDegrees) AngleRaw là 0.01°/LSB.
Sai 25× khiến điểm bị gán nhầm góc → một phòng bị bôi thành vòng tròn trên
RViz. Đã verify với thiết bị thật OLELR-1FMI: sau khi sửa ra 2400 điểm/vòng,
0–359.9°, đúng hình học môi trường.

- Đổi hệ số góc 0.25° → 0.01° trong parse_family_b().
- Bỏ qua block invalid (AngleRaw >= 0xFF00) theo spec.
- Dò ranh giới vòng quay PER-POINT thay vì per-packet (một gói có thể chứa
  >1 vòng), tránh gộp nhiều vòng vào một scan.
- Thêm model LR-1FMI (360°, 0.01°/LSB, ~2400 pts/rev) vào bảng model +
  kModelTable, đặt "1FMI" trước "1F" để khớp đúng chuỗi tên.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:14:52 +07:00

347 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Lidarlib
Thư viện C++17 cho lidar (OLEI + SICK), build bằng CMake ra **shared lib `.so`**
(`lidarlib::lidarlib`), hỗ trợ `find_package()` để link vào project khác. Tự nhận
diện họ giao thức (Family A/B/C) theo từng gói, hỗ trợ chạy nhiều lidar song
song, gộp cả OLEI (UDP) và SICK (TCP) sau **một hàm config duy nhất**
`lidarlib::make_lidar()`. **Không có Web UI** — người dùng tự viết giao diện trên
API C++ này (include header + link `.so`).
## Tóm tắt API (cho người viết giao diện)
```cpp
#include "lidarlib/lidarlib.hpp" // gộp toàn bộ API trong 1 include
// 1) MỘT hàm config: từ LidarConfig -> handle chung (OLEI hoặc SICK)
lidarlib::LidarConfig c{"front", "192.168.1.10", 2368, "AUTO", false, "OLEI"};
std::unique_ptr<lidarlib::Lidar> lidar = lidarlib::make_lidar(c);
lidar->open();
// 2) HAI loại dữ liệu mỗi vòng quét
lidarlib::ScanResult r;
lidar->recv_scan(r, 1000);
// r.scan : lidarlib::LaserScan — chung cho mọi lidar, đúng format sensor_msgs/LaserScan của ROS
// r.info : lidarlib::ExtraInfo — thông tin thêm, tuỳ family/model thực tế
// (tuỳ chọn) lưu/đọc danh sách lidar ra file:
lidarlib::Config cfg = lidarlib::load_config("config.json");
lidarlib::save_config("config.json", cfg);
```
`lidarlib::Lidar` là interface chung; `lidarlib::Driver` (OLEI/UDP) và `lidarlib::SickDriver`
(SICK/TCP) đều kế thừa nó, nên giao diện chỉ cần thao tác qua `lidarlib::Lidar*`
không phải phân biệt hãng. Vẫn có thể `new` thẳng `Driver`/`SickDriver` nếu muốn.
## Kiến trúc
| File | Vai trò |
|------|---------|
| `include/lidarlib/lidarlib.hpp` | Header tổng hợp — include 1 dòng là có cả data model + 2 driver + config + factory |
| `include/lidarlib/lidar.hpp` | API public: `LaserScan`, `ExtraInfo`, `ScanResult`, `ModelConfig`, interface `Lidar`, class `Driver` |
| `src/olei_lidar.cpp` | Parse Family A (0xFAF0), Family B (0xFEF0), Family C/V3 (0xFEAC), CRC, gom scan |
| `include/lidarlib/config.hpp` + `src/olei_config.cpp` | `Config`/`LidarConfig` (gồm `brand`: `"OLEI"`/`"SICK"`), load/save `config.json`, tra cứu `ModelConfig` theo tên/theo hãng, và **hàm config `make_lidar()`** |
| `src/json_mini.hpp` | Parser/serializer JSON tối giản, chỉ dùng nội bộ cho `olei_config` (load/save `config.json`) |
| `include/lidarlib/sick_lidar.hpp` + `src/sick_lidar.cpp` | Driver riêng cho lidar **SICK TiM5xx/7xx** — giao thức SOPAS/CoLa-A qua TCP (port 2111), khác hoàn toàn UDP binary của OLEI. **Đã verify bằng TiM781S thật** (xem mục riêng bên dưới) |
| `examples/example.cpp` | Demo 1 lidar, `recv_scan()` blocking |
| `examples/test_dual.cpp` | Demo 2 lidar song song (2 thread) |
| `examples/sick_example.cpp` | Demo driver SICK TiM, `recv_scan()` blocking qua TCP |
| `examples/lidar_app.cpp` | Khung app headless: đọc `config.json``make_lidar()` từng con → in scan. Thay vòng `printf` bằng giao diện của bạn |
| `CMakeLists.txt` | Build `lidarlib` (OLEI + SICK + factory + config, chỉ phụ thuộc pthread) thành `.so`, cài `install()`/`find_package()` |
Ba họ giao thức được driver tự nhận diện theo Frame ID/magic trong từng gói:
- **Family A** (`0xFAF0`) — VB/VF/LR-1F. Header 20B + block 3B/điểm. Có CRC32.
- **Family B** (`0xFEF0`) — LR-1BS5/LR-1BS2. Header 40B (preamble `0x010F` +
frame id ở offset [2-3], chuỗi tên model ASCII ở offset [7-17)) + block
8B/điểm.
- **Family C / protocol V3** (`0xFEAC`) — GS1-5. Header 48B, block 2/4B/điểm
tùy byte `Types`. **Port từ driver C# `OleiGS15Driver.cs`
(RobotNet10.RobotApp), CHƯA verify bằng phần cứng GS1-5 thật** (không có
thiết bị để sniff) — chỉ test bằng packet giả lập tự dựng theo đúng cấu trúc
header.
## Output: 2 loại
`Driver::recv_scan()` (và callback `set_scan_callback`) trả về
`ScanResult { LaserScan scan; ExtraInfo info; }` mỗi khi gom đủ 1 vòng quay:
```cpp
lidarlib::Driver drv(lidarlib::MODEL_AUTO, "192.168.100.100", 2369);
drv.open();
lidarlib::ScanResult result;
drv.recv_scan(result, 2000);
printf("%zu diem, model=%s\n", result.scan.ranges.size(), result.info.detected_model.c_str());
```
**`LaserScan`** — cùng tên field/đơn vị với `sensor_msgs/LaserScan` của ROS
(radian, mét, giây):
| Field | Ý nghĩa |
|---|---|
| `timestamp_ms` | Đồng hồ thiết bị (ms từ lúc bật nguồn); = 0 nếu family không có (xem `ExtraInfo`) |
| `angle_min`/`angle_max`/`angle_increment` | rad — đã unwrap liên tục, KHÔNG bị giới hạn `[-π,π]` |
| `time_increment`/`scan_time` | Luôn = 0 — thiết bị không báo timing chi tiết đó |
| `range_min`/`range_max` | m — lấy từ `ModelConfig` (giá trị đặt sẵn, **không đo được mỗi scan**) |
| `ranges[]`/`intensities[]` | m / 0-255 (đọc lại thành float như ROS) |
**`ExtraInfo`** — thông tin thêm tuỳ family/model thực tế của packet, field
nào thiết bị không có thì giữ `std::nullopt`:
| Field | Family | Verify hardware? |
|---|---|---|
| `detected_model` | mọi family (qua `MODEL_AUTO`) | Family B verify bằng sniff sống |
| `error_status` | Family A | Verify |
| `distance_scale_mm` | Family A/B | Verify |
| `rotation_raw` | Family A | Raw, chưa decode ý nghĩa |
| `distance_ratio_raw`, `scan_frequency_raw`, `input_status`, `output_status`, `field_status`, `status_flags` | Family C/GS1-5 | Raw, **chưa verify hardware thật** |
Vì sao cần "unwrap": góc từng điểm được lọc theo FOV ở hệ **có dấu**
`[-180, 180]` (0 = phía trước, dương = bên trái) — nhưng hệ này gãy ở biên
±180° đối với lidar quét 360°. Trước khi đưa vào `LaserScan`, driver unwrap
lại thành một dải góc liên tục trong từng vòng quay (`Driver::push_point()`
trong `src/olei_lidar.cpp`), nên `angle_min`/`angle_max`/`ranges[]` luôn đơn
điệu — đúng kiểu mảng mà `sensor_msgs/LaserScan` kỳ vọng.
## Kết nối lidar
Mạng tham chiếu trên host này (`/home/robotics`):
```
eth0: 192.168.100.100/24
├─ front (scan_1): DeviceIp 192.168.100.11, DevicePort 2368
└─ rear (scan_2): DeviceIp 192.168.100.12, DevicePort 2369
```
(Khớp với `RobotApp/RobotNet10.RobotApp/appsettings.json`, các mục
`Olei-front`/`Olei-rear`.)
Kiểm tra kết nối trước khi test:
```bash
ip -4 addr show eth0 # phải thấy inet 192.168.100.100/24
ping -c1 192.168.100.11 # front
ping -c1 192.168.100.12 # rear
```
**Lưu ý quan trọng:** nếu `RobotNet10.RobotApp` đang chạy, nó bind sẵn port
2368/2369 (không bật `SO_REUSEPORT`) → driver standalone sẽ bind lỗi
(`Khong mo duoc socket... interface khong ton tai?`). Kiểm tra ai đang giữ port:
```bash
ss -lunp | grep -E '2368|2369'
```
## Build
```bash
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"
```
Sinh ra `build/liblidarlib.so` (driver OLEI + SICK + factory + config, không
phụ thuộc gì ngoài pthread) và 4 binary demo (`example`, `test_dual`,
`sick_example`, `lidar_app`). Tắt build demo bằng `-DLIDARLIB_BUILD_EXAMPLES=OFF`.
Muốn ra static lib `.a` thay vì `.so` thì thêm `-DBUILD_SHARED_LIBS=OFF`.
Cài vào hệ thống để dùng `find_package(lidarlib)` từ project khác — `/usr/local`
chỉ root mới ghi được nên cần `sudo`, không thì `cmake --install` báo lỗi
permission denied:
```bash
sudo cmake --install build --prefix /usr/local
```
Muốn cài không cần `sudo` thì đổi prefix sang thư mục riêng trong `$HOME` (vd
`~/.local`), rồi thêm `-DCMAKE_PREFIX_PATH=~/.local` khi configure project nào
gọi `find_package(lidarlib)`:
```bash
cmake --install build --prefix "$HOME/.local"
```
```cmake
# trong CMakeLists.txt của project dùng thư viện này
find_package(lidarlib REQUIRED)
target_link_libraries(my_app PRIVATE lidarlib::lidarlib)
```
Sau khi link, giao diện chỉ cần `#include "lidarlib/lidarlib.hpp"` rồi gọi
`lidarlib::make_lidar()` — xem "Tóm tắt API" ở đầu README.
Vẫn có thể build từng file bằng g++ thuần nếu không muốn dùng CMake:
```bash
g++ -std=c++17 -O2 -pthread -Wall -Wextra -Iinclude -o test_dual examples/test_dual.cpp src/olei_lidar.cpp
g++ -std=c++17 -O2 -pthread -Wall -Wextra -Iinclude -o example examples/example.cpp src/olei_lidar.cpp
g++ -std=c++17 -O2 -pthread -Wall -Wextra -Iinclude -o lidar_app examples/lidar_app.cpp src/olei_lidar.cpp src/sick_lidar.cpp src/olei_config.cpp
g++ -std=c++17 -O2 -pthread -Wall -Wextra -Iinclude -o sick_example examples/sick_example.cpp src/sick_lidar.cpp
```
## Test thử
### 2 lidar song song
```bash
./build/test_dual
```
In ra 5 scan mỗi bên, kèm số điểm, timestamp, error status, và **model đã tự
dò được** (`model=...`).
### 1 lidar
Sửa model/IP/port trong `examples/example.cpp` rồi build lại, hoặc gọi trực tiếp:
```cpp
lidarlib::Driver drv(lidarlib::MODEL_AUTO, "192.168.100.100", 2369);
drv.open();
lidarlib::ScanResult result;
drv.recv_scan(result, 2000);
```
### Lidar lắp úp ngược
Constructor có tham số thứ 4 `inverted` (mặc định `false`). Đặt `true` nếu
thiết bị bị lắp lật 180° quanh trục hướng về phía trước — driver tự đảo dấu
góc từng điểm (`angle = -angle`, chuẩn hóa lại về `-180..180`) để output luôn
đúng theo hệ quy chiếu xe, không phụ thuộc hướng lắp vật lý:
```cpp
lidarlib::Driver drv(lidarlib::MODEL_AUTO, "192.168.100.100", 2369, /*inverted=*/true);
```
Đã verify bằng sniff sống: chạy `inverted=false` góc tăng dần theo thời gian,
chạy `inverted=true` góc giảm dần với cùng bước góc — đúng chữ ký của đảo dấu.
## Cấu hình & chạy (lidar_app + config.json)
Không còn Web UI. Cấu hình là một file JSON đơn giản — `examples/lidar_app.cpp`
đọc nó, mở từng lidar qua đúng **một hàm** `lidarlib::make_lidar()`, rồi đọc scan
trên mỗi thread. Đây là khung mẫu để bạn thay vòng `printf` bằng giao diện
riêng (Qt, ImGui, ROS node, v.v.).
```bash
./build/lidar_app # đọc/tạo config.json cạnh chỗ chạy
./build/lidar_app my_config.json # đường dẫn config khác
```
`config.json` — danh sách lidar, không cố định số lượng. `brand` chọn loại
driver (`"OLEI"` = UDP, `"SICK"` = TCP/SOPAS); `model` tra trong bảng
`ModelConfig` (tên lạ → tự lùi về mặc định của hãng: `AUTO` cho OLEI,
`SICK-TIM571` cho SICK); `inverted` chỉ có tác dụng với OLEI. Bỏ trống `brand`
thì mặc định `"OLEI"` (tương thích file cũ).
```json
{
"lidars": [
{"name":"front", "ip":"192.168.100.100","port":2368,"brand":"OLEI","model":"AUTO", "inverted":false},
{"name":"rear", "ip":"192.168.100.100","port":2369,"brand":"OLEI","model":"AUTO", "inverted":true},
{"name":"sick1", "ip":"192.168.0.1", "port":2111,"brand":"SICK","model":"SICK-TIM571", "inverted":false}
]
}
```
Trong code, đọc/ghi file bằng `lidarlib::load_config(path)` / `lidarlib::save_config(path, cfg)`
(file hỏng → trả về mặc định, không crash). Giao diện của bạn tự quyết khi nào
lưu — thư viện không tự bind port hay phục vụ HTTP gì cả.
### Sniff packet thô (debug khi nghi ngờ offset header)
```bash
python3 - <<'EOF'
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("192.168.100.100", 2369)) # đổi port theo lidar cần xem
data, addr = s.recvfrom(4096)
print("from", addr, "len", len(data))
print(data[:40].hex(' '))
EOF
```
Luôn ưu tiên capture thật hơn là tin comment trong code — comment mô tả ý
định lúc viết, không phải offset đã verify trên thiết bị thật.
## Chọn `ModelConfig`
| Constant | FOV (deg, có dấu) | range_min/max (m) | Khi dùng |
|---|---|---|---|
| `MODEL_VB` | -135…135 | 0.05…30 | 2D 270°, Family A |
| `MODEL_VF` | -180…180 | 0.05…30 | 2D 360°, Family A |
| `MODEL_LR1F` | -180…180 | 0.05…50 | 2D 360° 50m, Family A |
| `MODEL_LR1BS5` | -180…180 | 0.05…30 | 2D 360°, Family B |
| `MODEL_LR16F` | -135…135 | 0.05…30 | 3D 16-line |
| `MODEL_GS15` | -180…180 | 0.05…30 | 2D 360°, Family C/V3 — **chưa verify hardware thật** |
| `MODEL_AUTO` | -180…180 (mặc định, có thể tự thu hẹp) | 0.05…30 | Không biết trước model |
`range_min_m`/`range_max_m` chỉ là giá trị đặt sẵn để điền vào
`LaserScan::range_min/range_max` (không đọc từ packet) — chỉnh trong
`include/lidarlib/lidar.hpp` theo datasheet thật của từng model nếu cần chính xác.
`MODEL_AUTO`: chỉ có tác dụng tự-dò với **Family B** (đọc chuỗi tên model
trong header). Driver luôn lưu lại **tên thật** đọc từ packet (vd
`"OLELR-1BS5"`, `"OLELR-1BS2"`) — gọi `drv.detected_model()` hoặc
`result.info.detected_model` để xem (trả về `"AUTO"` nếu chưa nhận gói Family B
nào). FOV chỉ tự thu hẹp khi tên đó khớp một entry trong `kModelTable`
(`src/olei_lidar.cpp`); nếu không khớp, FOV giữ nguyên mặc định 360°
(`-180..180`, không mất điểm) — an toàn nhưng có thể giữ lại điểm ngoài FOV
thật của thiết bị nếu thiết bị đó không quét tròn.
Family A không mang chuỗi tên model trong packet, nên `MODEL_AUTO` trên thiết
bị Family A cũng giữ nguyên FOV rộng — phải chỉ định model cụ thể (VD
`MODEL_VB`) nếu muốn thu hẹp FOV cho thiết bị góc hẹp.
## Lidar SICK (TiM5xx/7xx) — driver riêng
`lidarlib::SickDriver` (`include/lidarlib/sick_lidar.hpp` + `src/sick_lidar.cpp`) là
driver **độc lập** với `lidarlib::Driver` ở trên — không phải thêm 1 family vào
driver OLEI, vì giao thức khác hẳn:
- Kết nối **TCP** (SOPAS, port mặc định 2111) tới lidar, không phải UDP
broadcast như OLEI.
- Telegram là **ASCII** (CoLa-A), đóng khung bằng `STX`(0x02)/`ETX`(0x03),
không có CRC32 như Family A.
- Thiết bị đứng im cho tới khi driver gửi lệnh `sEN LMDscandata 1``open()`
tự làm việc này; `close()` gửi `sEN LMDscandata 0` trước khi đóng socket.
Output vẫn dùng chung `ScanResult`/`LaserScan`/`ExtraInfo` như driver OLEI nên
gọi giống hệt:
```cpp
lidarlib::SickDriver drv(lidarlib::MODEL_SICK_TIM571, "192.168.0.1", 2111);
drv.open();
lidarlib::ScanResult result;
drv.recv_scan(result, 2000);
```
| Constant | FOV (deg, có dấu) | range_min/max (m) | Khi dùng |
|---|---|---|---|
| `MODEL_SICK_TIM5XX` | -135…135 | 0.05…10 | TiM551/561, 270° |
| `MODEL_SICK_TIM571` | -135…135 | 0.05…25 | TiM571, 270° |
| `MODEL_SICK_TIM7XX` | -135…135 | 0.05…25 | TiM781, 270° |
**Đã verify bằng phần cứng thật**: chạy trực tiếp với 1 con **SICK TiM781S**
(FW `V5.11-14.10.24`, DeviceIdent đọc qua `sRN DeviceIdent` trên cổng 2111)
tại `192.168.100.22:2111``MODEL_SICK_TIM7XX`. Kết quả khớp đúng datasheet
TiM781S: 811 điểm/scan trải từ -45°…225° (270° FOV), `angle_increment` =
0.333° (1/3°, đúng độ phân giải góc của dòng 781), khoảng cách 0.3-1.5m ổn
định qua nhiều scan liên tiếp, kênh `RSSI1` có giá trị intensity hợp lý
(không phải toàn 0), `error_status` = 0x00. Điều này xác nhận layout
`LMDscandata` trong `parse_lmdscandata()` (`src/sick_lidar.cpp`) — kênh
`DIST1`/`RSSI1`, scaling factor IEEE-754, start angle/step width — đọc đúng
trên hardware thật, không chỉ đúng theo tài liệu nữa.
**Vẫn chưa verify**: chiều quy ước góc 0° (thẳng phía trước thiết bị hay
hướng khác — chưa đối chiếu với hướng lắp vật lý thật), thiết bị có encoder
(`NumEncoders > 0`, nhánh `next()`×2 chưa từng chạy qua vì test thực tế không
có encoder), và nhánh 8-bit channel (`Num8BitChannels`, thiết bị test chỉ
dùng kênh 16-bit). `MODEL_SICK_TIM5XX`/`MODEL_SICK_TIM571` (FOV/range theo
datasheet) cũng chưa test trên phần cứng — chỉ `MODEL_SICK_TIM7XX` đã chạy
thật.
`SickDriver` nối vào `config.json` qua field `LidarConfig::brand` (`"OLEI"`
hoặc `"SICK"`, mặc định `"OLEI"`): `lidarlib::make_lidar()` thấy `brand=="SICK"` thì
trả về `lidarlib::SickDriver` (TCP/SOPAS) thay cho `lidarlib::Driver` (UDP), cùng kiểu
trả về `std::unique_ptr<lidarlib::Lidar>` nên phía gọi không phải phân biệt. Xem
`examples/lidar_app.cpp` (chạy theo config) hoặc `examples/sick_example.cpp`
(dùng thẳng `SickDriver` qua API C++).