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>
This commit is contained in:
2026-07-01 10:14:52 +07:00
commit 59880871b0
17 changed files with 2288 additions and 0 deletions

346
README.md Normal file
View File

@@ -0,0 +1,346 @@
# 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++).