-
Notifications
You must be signed in to change notification settings - Fork 35
Feature: nativemodule livox and fastlio2 #1235
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
52 commits
Select commit
Hold shift + click to select a range
f954e86
mid360 livox integration
leshy 6618eed
Fix LiDAR module port resolution broken by `from __future__ import an…
leshy 2746433
Move LivoxLidarModule into livox/ directory
leshy 1bbb3fb
livox voxelization
leshy 0474e74
native module implementation
leshy ba87583
livox native module wrap
leshy 8cd4ce9
bugfix
leshy 5c5f100
removed py livox implementation entirely
leshy e24ea8e
correct config defaults
leshy 71ee68d
Add FAST-LIO2 native module with Livox Mid-360 SLAM integration
leshy a84cc09
Fetch FAST-LIO-NON-ROS from GitHub instead of hardcoded local path
leshy cb8fcdc
ruff bugfix
leshy d345e82
nicer fastlio config
leshy bcda438
correct mid360 config
leshy 28c80ac
fastlio filtering, tuning
leshy e1f06ad
cpp mapper
leshy cf6725d
renamed mid360 module
leshy a07d839
nix build for livox SDK
leshy 87a3b75
included lidar configs, voxel_map implementation
leshy 4c84894
nix flakes for fastlio2_native and mid360_native builds
leshy 4ff322d
clean up native module build: consolidate install paths, simplify mai…
leshy f370d06
readme files for both modules
leshy 2c75f06
update fastlio blueprints: adjust voxel size and reorder configs
leshy a5a014a
Merge remote-tracking branch 'origin/dev' into feat/livox
leshy 8f4ae47
all blueprints update
leshy e3c84de
fix mypy errors and rename test_spec_compliance to test_spec
leshy 5893805
fix test_autoconnect: compare Topic.topic string instead of Topic object
leshy 9592b25
removed useless file
leshy 96619c0
rerun bridge cli run fix
leshy 36bb19a
replace /tmp config files with memfd_create, extract shared livox_sdk…
leshy af4e732
auto-fetch FAST-LIO-NON-ROS when FASTLIO_DIR not set
leshy a1ce9fc
move dimos_native_module.hpp to common/ to eliminate duplication
leshy bbe220e
rename Mid360Module to Mid360, use lidar port in Lidar spec
leshy b41986c
rename spec ports: pointcloud→lidar, global_pointcloud→global_map
leshy 07673f8
cleanup
leshy 77855d9
small fixes
leshy ce406c8
small fixes 2
leshy 3780beb
Merge branch 'dev' into feat/livox
leshy b493be5
Merge branch 'dev' into feat/livox
leshy d6ebd74
initial documentation for native modules
leshy e0ab2db
added an automatic build system
leshy e5d1184
moved native echo, small readme updates
leshy fdac8b8
small readme correction
leshy 880b617
moved lcm info to lcm doc
leshy 3142b5b
lcm performance note
leshy 507651c
small readme fixes
leshy 7c41b6e
Replace assert_implements_protocol with mypy-level protocol checks
leshy 7a75559
Address PR review: simplify NativeModule threads and test fixture
leshy f83642e
Switch native_echo test helper from env vars to CLI args
leshy 4e98ec2
Switch native_echo from env vars to CLI args, fix mypy annotations
leshy 707206f
Merge remote-tracking branch 'origin/dev' into feat/livox
leshy 17f052c
Merge remote-tracking branch 'origin/dev' into feat/livox
leshy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,6 +65,7 @@ yolo11n.pt | |
|
|
||
| *mobileclip* | ||
| /results | ||
| **/cpp/result | ||
|
|
||
| CLAUDE.MD | ||
| /assets/teleop_certs/ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,296 @@ | ||
| # Copyright 2026 Dimensional Inc. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """NativeModule: blueprint-integrated wrapper for native (C/C++) executables. | ||
|
|
||
| A NativeModule is a thin Python Module subclass that declares In/Out ports | ||
| for blueprint wiring but delegates all real work to a managed subprocess. | ||
| The native process receives its LCM topic names via CLI args and does | ||
| pub/sub directly on the LCM multicast bus. | ||
|
|
||
| Example usage:: | ||
|
|
||
| @dataclass(kw_only=True) | ||
| class MyConfig(NativeModuleConfig): | ||
| executable: str = "./build/my_module" | ||
| some_param: float = 1.0 | ||
|
|
||
| class MyCppModule(NativeModule): | ||
| default_config = MyConfig | ||
| pointcloud: Out[PointCloud2] | ||
| cmd_vel: In[Twist] | ||
|
|
||
| # Works with autoconnect, remappings, etc. | ||
| autoconnect( | ||
| MyCppModule.blueprint(), | ||
| SomeConsumer.blueprint(), | ||
| ).build().loop() | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field, fields | ||
| import enum | ||
| import inspect | ||
| import json | ||
| import os | ||
| from pathlib import Path | ||
| import signal | ||
| import subprocess | ||
| import threading | ||
| from typing import IO, Any | ||
|
|
||
| from dimos.core.core import rpc | ||
| from dimos.core.module import Module, ModuleConfig | ||
| from dimos.utils.logging_config import setup_logger | ||
|
|
||
| logger = setup_logger() | ||
|
|
||
|
|
||
| class LogFormat(enum.Enum): | ||
| TEXT = "text" | ||
| JSON = "json" | ||
|
|
||
|
|
||
| @dataclass(kw_only=True) | ||
| class NativeModuleConfig(ModuleConfig): | ||
| """Configuration for a native (C/C++) subprocess module.""" | ||
|
|
||
| executable: str | ||
| build_command: str | None = None | ||
| cwd: str | None = None | ||
| extra_args: list[str] = field(default_factory=list) | ||
| extra_env: dict[str, str] = field(default_factory=dict) | ||
| shutdown_timeout: float = 10.0 | ||
| log_format: LogFormat = LogFormat.TEXT | ||
|
|
||
| # Override in subclasses to exclude fields from CLI arg generation | ||
| cli_exclude: frozenset[str] = frozenset() | ||
|
|
||
| def to_cli_args(self) -> list[str]: | ||
| """Auto-convert subclass config fields to CLI args. | ||
|
|
||
| Iterates fields defined on the concrete subclass (not NativeModuleConfig | ||
| or its parents) and converts them to ``["--name", str(value)]`` pairs. | ||
| Skips fields whose values are ``None`` and fields in ``cli_exclude``. | ||
| """ | ||
| ignore_fields = {f.name for f in fields(NativeModuleConfig)} | ||
| args: list[str] = [] | ||
| for f in fields(self): | ||
| if f.name in ignore_fields: | ||
| continue | ||
| if f.name in self.cli_exclude: | ||
| continue | ||
| val = getattr(self, f.name) | ||
| if val is None: | ||
| continue | ||
| if isinstance(val, bool): | ||
| args.extend([f"--{f.name}", str(val).lower()]) | ||
| elif isinstance(val, list): | ||
| args.extend([f"--{f.name}", ",".join(str(v) for v in val)]) | ||
| else: | ||
| args.extend([f"--{f.name}", str(val)]) | ||
| return args | ||
|
|
||
|
|
||
| class NativeModule(Module[NativeModuleConfig]): | ||
| """Module that wraps a native executable as a managed subprocess. | ||
|
|
||
| Subclass this, declare In/Out ports, and set ``default_config`` to a | ||
| :class:`NativeModuleConfig` subclass pointing at the executable. | ||
|
|
||
| On ``start()``, the binary is launched with CLI args:: | ||
|
|
||
| <executable> --<port_name> <lcm_topic_string> ... <extra_args> | ||
|
|
||
| The native process should parse these args and pub/sub on the given | ||
| LCM topics directly. On ``stop()``, the process receives SIGTERM. | ||
| """ | ||
|
|
||
| default_config: type[NativeModuleConfig] = NativeModuleConfig | ||
| _process: subprocess.Popen[bytes] | None = None | ||
| _watchdog: threading.Thread | None = None | ||
| _stopping: bool = False | ||
|
|
||
| def __init__(self, *args: Any, **kwargs: Any) -> None: | ||
| super().__init__(*args, **kwargs) | ||
| self._resolve_paths() | ||
|
|
||
| @rpc | ||
| def start(self) -> None: | ||
| if self._process is not None and self._process.poll() is None: | ||
| logger.warning("Native process already running", pid=self._process.pid) | ||
| return | ||
|
|
||
| self._maybe_build() | ||
|
|
||
| topics = self._collect_topics() | ||
|
|
||
| cmd = [self.config.executable] | ||
| for name, topic_str in topics.items(): | ||
| cmd.extend([f"--{name}", topic_str]) | ||
| cmd.extend(self.config.to_cli_args()) | ||
| cmd.extend(self.config.extra_args) | ||
|
|
||
| env = {**os.environ, **self.config.extra_env} | ||
| cwd = self.config.cwd or str(Path(self.config.executable).resolve().parent) | ||
|
|
||
| logger.info("Starting native process", cmd=" ".join(cmd), cwd=cwd) | ||
| self._process = subprocess.Popen( | ||
| cmd, | ||
| env=env, | ||
| cwd=cwd, | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.PIPE, | ||
| ) | ||
| logger.info("Native process started", pid=self._process.pid) | ||
|
|
||
| self._stopping = False | ||
| self._watchdog = threading.Thread(target=self._watch_process, daemon=True) | ||
| self._watchdog.start() | ||
|
|
||
| @rpc | ||
| def stop(self) -> None: | ||
| self._stopping = True | ||
| if self._process is not None and self._process.poll() is None: | ||
| logger.info("Stopping native process", pid=self._process.pid) | ||
| self._process.send_signal(signal.SIGTERM) | ||
| try: | ||
| self._process.wait(timeout=self.config.shutdown_timeout) | ||
| except subprocess.TimeoutExpired: | ||
| logger.warning( | ||
| "Native process did not exit, sending SIGKILL", pid=self._process.pid | ||
| ) | ||
| self._process.kill() | ||
| self._process.wait(timeout=5) | ||
| if self._watchdog is not None and self._watchdog is not threading.current_thread(): | ||
| self._watchdog.join(timeout=2) | ||
| self._watchdog = None | ||
| self._process = None | ||
| super().stop() | ||
|
|
||
| def _watch_process(self) -> None: | ||
| """Block until the native process exits; trigger stop() if it crashed.""" | ||
| if self._process is None: | ||
| return | ||
|
|
||
| stdout_t = self._start_reader(self._process.stdout, "info") | ||
| stderr_t = self._start_reader(self._process.stderr, "warning") | ||
| rc = self._process.wait() | ||
| stdout_t.join(timeout=2) | ||
| stderr_t.join(timeout=2) | ||
|
|
||
| if self._stopping: | ||
| return | ||
| logger.error( | ||
| "Native process died unexpectedly", | ||
| pid=self._process.pid, | ||
| returncode=rc, | ||
| ) | ||
| self.stop() | ||
|
|
||
| def _start_reader(self, stream: IO[bytes] | None, level: str) -> threading.Thread: | ||
| """Spawn a daemon thread that pipes a subprocess stream through the logger.""" | ||
| t = threading.Thread(target=self._read_log_stream, args=(stream, level), daemon=True) | ||
| t.start() | ||
| return t | ||
|
|
||
| def _read_log_stream(self, stream: IO[bytes] | None, level: str) -> None: | ||
| if stream is None: | ||
| return | ||
| log_fn = getattr(logger, level) | ||
| for raw in stream: | ||
| line = raw.decode("utf-8", errors="replace").rstrip() | ||
| if not line: | ||
| continue | ||
| if self.config.log_format == LogFormat.JSON: | ||
| try: | ||
| data = json.loads(line) | ||
| event = data.pop("event", line) | ||
| log_fn(event, **data) | ||
| continue | ||
| except (json.JSONDecodeError, TypeError): | ||
| logger.warning("malformed JSON from native module", raw=line) | ||
| log_fn(line, pid=self._process.pid if self._process else None) | ||
| stream.close() | ||
|
|
||
| def _resolve_paths(self) -> None: | ||
| """Resolve relative ``cwd`` and ``executable`` against the subclass's source file.""" | ||
| if self.config.cwd is not None and not Path(self.config.cwd).is_absolute(): | ||
| source_file = inspect.getfile(type(self)) | ||
| base_dir = Path(source_file).resolve().parent | ||
| self.config.cwd = str(base_dir / self.config.cwd) | ||
| if not Path(self.config.executable).is_absolute() and self.config.cwd is not None: | ||
| self.config.executable = str(Path(self.config.cwd) / self.config.executable) | ||
|
|
||
| def _maybe_build(self) -> None: | ||
| """Run ``build_command`` if the executable does not exist.""" | ||
| exe = Path(self.config.executable) | ||
| if exe.exists(): | ||
| return | ||
| if self.config.build_command is None: | ||
| raise FileNotFoundError( | ||
| f"Executable not found: {exe}. " | ||
| "Set build_command in config to auto-build, or build it manually." | ||
| ) | ||
| logger.info( | ||
| "Executable not found, running build", | ||
| executable=str(exe), | ||
| build_command=self.config.build_command, | ||
| ) | ||
| proc = subprocess.Popen( | ||
| self.config.build_command, | ||
| shell=True, | ||
| cwd=self.config.cwd, | ||
| env={**os.environ, **self.config.extra_env}, | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.PIPE, | ||
| ) | ||
| stdout, stderr = proc.communicate() | ||
| for line in stdout.decode("utf-8", errors="replace").splitlines(): | ||
| if line.strip(): | ||
| logger.info(line) | ||
| for line in stderr.decode("utf-8", errors="replace").splitlines(): | ||
| if line.strip(): | ||
| logger.warning(line) | ||
| if proc.returncode != 0: | ||
| raise RuntimeError( | ||
| f"Build command failed (exit {proc.returncode}): {self.config.build_command}" | ||
| ) | ||
| if not exe.exists(): | ||
| raise FileNotFoundError( | ||
| f"Build command succeeded but executable still not found: {exe}" | ||
| ) | ||
|
|
||
| def _collect_topics(self) -> dict[str, str]: | ||
| """Extract LCM topic strings from blueprint-assigned stream transports.""" | ||
| topics: dict[str, str] = {} | ||
| for name in list(self.inputs) + list(self.outputs): | ||
| stream = getattr(self, name, None) | ||
| if stream is None: | ||
| continue | ||
| transport = getattr(stream, "_transport", None) | ||
| if transport is None: | ||
| continue | ||
| topic = getattr(transport, "topic", None) | ||
| if topic is not None: | ||
| topics[name] = str(topic) | ||
| return topics | ||
|
|
||
|
|
||
| __all__ = [ | ||
| "LogFormat", | ||
| "NativeModule", | ||
| "NativeModuleConfig", | ||
| ] | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The timeout feels a bit weird being hardcoded (some systems are slower than others) but I agree it doesnt really feel like a config value. Maybe having it as a private class parameter would be good.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SIGKILL is non optional, this is an operating system level termination, that's why only SIGTERM timeout is configurable
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I meant more why have a timeout on the wait? Why not set it to 0? Probably cause it takes some time. How much time? Well that probably depends if its an under clocked raspberry pi or Pim's supercomputer.
Is 5 a sane number to cover all cases? Probably. But then why are the t.join's hardcoded to 2 sec instead of 5? Idk. Feels kinda weird, but not a big deal.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah I think it's a balance between configurability and ergonomics, this config is my people facing interface, so the fact that a person would need to read and think about this value is not worth it - given likely no one ever actually needs to read and think about this.
this value is orders of magnitude above what's expected by normal system operation even on weakest machines, so if it takes more then 5 seconds to SIGKILL - don't worry about that part, your kernel isn't working and you are unplugging the power anyway, you are not thinking of reconfiguring native_module.py
if above assumption proves wrong can add or change