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>
17 KiB
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)
#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* mà
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 (preamble0x010F+ 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 byteTypes. 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:
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:
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:
ss -lunp | grep -E '2368|2369'
Build
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:
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):
cmake --install build --prefix "$HOME/.local"
# 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:
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
./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:
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ý:
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.).
./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ũ).
{
"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)
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ửisEN LMDscandata 0trước khi đóng socket.
Output vẫn dùng chung ScanResult/LaserScan/ExtraInfo như driver OLEI nên
gọi giống hệt:
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++).