franka/model/model_library.rs
1//! Downloading the FCI v5 model library from the robot (`LoadModelLibrary`).
2//!
3//! Port of `franka::LibraryDownloader` (`src/library_downloader.{h,cpp}`,
4//! libfranka 0.9.2). On FCI v5 the robot has no URDF to hand out; instead it
5//! serves a *compiled* model for the architecture and operating system the
6//! client asks for, and libfranka `dlopen`s it. The download is a single
7//! command on the ordinary TCP command socket:
8//!
9//! * request: `u8 architecture, u8 system` (2 bytes),
10//! * response: `u8 status`, followed by the shared object as the message tail
11//! (`header.size - 13` bytes: the 12-byte command header plus the status byte
12//! are subtracted).
13//!
14//! With the opt-in `model-library` feature, [`load_from_robot`] performs that
15//! exchange and hands the bytes to `SoModelBackend::from_bytes`; the offline
16//! entry points `Model::from_model_library_bytes` and
17//! `Model::from_model_library_path` skip the network. Without the feature,
18//! [`load_from_robot`] returns an error without requesting any bytes.
19
20#[cfg(feature = "model-library")]
21use std::path::Path;
22
23#[cfg(feature = "model-library")]
24use zerocopy::IntoBytes;
25
26use crate::error::{FrankaError, FrankaResult};
27#[cfg(feature = "model-library")]
28use crate::model::so_backend::SoModelBackend;
29use crate::model::Model;
30use crate::network::Network;
31use crate::wire::robot::codec::{command_id, CommandKind, FciVersion};
32#[cfg(feature = "model-library")]
33use crate::wire::robot::v5;
34#[cfg(feature = "model-library")]
35use crate::wire::{message_payload, parse_response, HeaderLayout};
36
37/// The wire command id of `LoadModelLibrary` under `version`.
38///
39/// # Errors
40///
41/// [`FrankaError::InvalidOperation`] on a version that has no such command,
42/// i.e. FCI v10, whose FR3 serves a URDF through `GetRobotModel` instead.
43fn load_model_library_command(version: FciVersion) -> FrankaResult<u32> {
44 command_id(version, CommandKind::LoadModelLibrary).ok_or_else(|| {
45 FrankaError::InvalidOperation(format!(
46 "libfranka: {} is not available on FCI version {}.",
47 CommandKind::LoadModelLibrary.name(),
48 version.number()
49 ))
50 })
51}
52
53/// The `LoadModelLibrary::Architecture` this build asks the robot for.
54///
55/// libfranka picks the value from the `LIBFRANKA_X64` / `LIBFRANKA_X86` /
56/// `LIBFRANKA_ARM64` / `LIBFRANKA_ARM` macros its CMake sets from the target
57/// processor (`src/platform.h`, `library_downloader.cpp`); `cfg!(target_arch)`
58/// is the direct equivalent.
59///
60/// # Errors
61///
62/// [`FrankaError::Model`] with libfranka's own
63/// `"libfranka: Unsupported architecture!"` on any other target.
64#[cfg(feature = "model-library")]
65fn architecture() -> FrankaResult<v5::LoadModelLibraryArchitecture> {
66 if cfg!(target_arch = "x86_64") {
67 Ok(v5::LoadModelLibraryArchitecture::X64)
68 } else if cfg!(target_arch = "x86") {
69 Ok(v5::LoadModelLibraryArchitecture::X86)
70 } else if cfg!(target_arch = "aarch64") {
71 Ok(v5::LoadModelLibraryArchitecture::ARM64)
72 } else if cfg!(target_arch = "arm") {
73 Ok(v5::LoadModelLibraryArchitecture::ARM)
74 } else {
75 Err(FrankaError::Model(
76 "libfranka: Unsupported architecture!".to_string(),
77 ))
78 }
79}
80
81/// The `LoadModelLibrary::System` this build asks the robot for.
82///
83/// # Errors
84///
85/// [`FrankaError::Model`] with libfranka's own
86/// `"libfranka: Unsupported operating system!"` on any other target.
87#[cfg(feature = "model-library")]
88fn system() -> FrankaResult<v5::LoadModelLibrarySystem> {
89 if cfg!(target_os = "linux") {
90 Ok(v5::LoadModelLibrarySystem::Linux)
91 } else if cfg!(target_os = "windows") {
92 Ok(v5::LoadModelLibrarySystem::Windows)
93 } else {
94 Err(FrankaError::Model(
95 "libfranka: Unsupported operating system!".to_string(),
96 ))
97 }
98}
99
100/// Downloads the robot's model library over `network` and builds a [`Model`].
101///
102/// Port of `franka::Model::Model(Network&)` (0.9.2), which is
103/// `ModelLibrary(network)` and therefore `LibraryDownloader(network)` followed
104/// by `LibraryLoader`.
105///
106/// # Temp-file lifecycle
107///
108/// The downloaded bytes are written to a uniquely named, mode `0600` file under
109/// [`std::env::temp_dir`] (libfranka: `Poco::TemporaryFile::tempName()`), which
110/// is `dlopen`ed and then **removed when the returned [`Model`] is dropped** —
111/// the file guard lives inside the [`SoModelBackend`] the model owns. The guard
112/// covers a normal drop and an unwind, but not `SIGINT`/`SIGTERM`,
113/// `std::process::abort` or `std::process::exit`; a control loop killed with
114/// Ctrl-C therefore leaves one ~330 KB file behind in the temp directory. Two
115/// concurrent downloads (in the same process or in different ones) cannot
116/// collide on the name.
117///
118/// # Platform limitation
119///
120/// The robot serves a *native* shared object, so this only works where the
121/// robot has a build for the current `(architecture, system)` pair and where
122/// the running process can load it. The request encodes whatever the host is
123/// (`X64`/`X86`/`ARM64`/`ARM` and Linux/Windows, from `cfg!`), but in practice
124/// only **x86-64 Linux**
125/// works: the FER control unit ships `libfcimodels_x64.so`. Cross-compiled or
126/// unusual targets get [`FrankaError::Model`] from the robot
127/// (`"libfranka: Server reports error when loading model library."`) or from
128/// `dlopen`.
129///
130/// # Errors
131///
132/// * [`FrankaError::InvalidOperation`] when `version` is not
133/// [`FciVersion::V5`]: FCI v10 has no `LoadModelLibrary` command — an FR3
134/// serves a URDF through `GetRobotModel` instead.
135/// * [`FrankaError::Model`] when the target architecture or operating system
136/// has no `LoadModelLibrary` encoding, when the robot answers
137/// `Status::kError`, or when the shared object cannot be saved, loaded or
138/// bound.
139/// * [`FrankaError::Network`] / [`FrankaError::Protocol`] for the usual
140/// command-socket failures.
141#[cfg(feature = "model-library")]
142pub fn load_from_robot(network: &Network, version: FciVersion) -> FrankaResult<Model> {
143 let command = load_model_library_command(version)?;
144
145 let request = v5::LoadModelLibraryRequest::new(architecture()?, system()?);
146 let command_id = network.tcp.send_request(command, request.as_bytes())?;
147 let message = network.tcp.blocking_receive_response(command_id)?;
148 let response: v5::LoadModelLibraryResponse = parse_response(HeaderLayout::Robot, &message)?;
149
150 // libfranka compares against `kSuccess` and reports one message for every other value,
151 // including bytes that are not valid `Status` enumerators.
152 if response.status != v5::LoadModelLibraryStatus::Success.to_u8() {
153 return Err(FrankaError::Model(
154 "libfranka: Server reports error when loading model library.".to_string(),
155 ));
156 }
157
158 // The tail is everything after the 12-byte header and the status byte, i.e.
159 // `header.size - 13` bytes; `message_payload` strips the header and the `[1..]` the
160 // status. `parse_response` above has already established that the payload is non-empty.
161 let library_bytes = &message_payload(HeaderLayout::Robot, &message)[1..];
162 if library_bytes.is_empty() {
163 return Err(FrankaError::Protocol(
164 "libfranka: Incorrect TCP message size.".to_string(),
165 ));
166 }
167 // SAFETY: `library_bytes` is the `libfcimodels` build the connected robot served over
168 // the FCI command socket, and `dlopen`ing it executes its code. That is the trust
169 // boundary libfranka 0.9.2 sits on (`LibraryDownloader` + `LibraryLoader`): the FCI
170 // peer is already fully trusted, because it is the thing that commands the arm, and a
171 // caller opts in with the `model-library` feature. See the
172 // `# Security` section on `Robot::load_model_from_robot`.
173 unsafe { Model::from_model_library_bytes(library_bytes) }
174}
175
176/// [`load_from_robot`] as compiled **without** the `model-library` feature.
177///
178/// The signature is identical in both configurations so that callers
179/// (`RobotImpl::load_model`) need no `cfg` of their own; the version check still
180/// runs first, so an FCI v10 caller gets the same
181/// [`FrankaError::InvalidOperation`] either way, and an FCI v5 caller gets a
182/// [`FrankaError::Model`] explaining that this build cannot load one. No bytes
183/// are requested from the robot, because there would be nothing to `dlopen`
184/// them with.
185#[cfg(not(feature = "model-library"))]
186pub fn load_from_robot(_network: &Network, version: FciVersion) -> FrankaResult<Model> {
187 load_model_library_command(version)?;
188 Err(FrankaError::Model(
189 "libfranka: this build of franka-rs was compiled without the `model-library` \
190 feature, so the FCI v5 model library cannot be loaded."
191 .to_string(),
192 ))
193}
194
195#[cfg(feature = "model-library")]
196impl Model {
197 /// Builds a model from the bytes of a `libfcimodels` shared object.
198 ///
199 /// The offline twin of [`load_from_robot`]: same temp-file lifecycle (a
200 /// `0600` file under [`std::env::temp_dir`], removed when the model is
201 /// dropped), no network. Useful for a captured library and for tests.
202 ///
203 /// # Safety
204 ///
205 /// The bytes are written to a temporary file and `dlopen`ed, which executes
206 /// the shared object's initialisers and, afterwards, its exported entry
207 /// points in this process. The caller asserts that `bytes` are a **trusted**
208 /// `libfcimodels` build for the current platform — served by a Franka
209 /// control unit, or captured from one and kept where only trusted
210 /// principals can write. See [`SoModelBackend::from_bytes`].
211 ///
212 /// # Errors
213 ///
214 /// [`FrankaError::Model`] when the file cannot be written, `dlopen`ed, or
215 /// when any of the thirty `libfcimodels` symbols is missing.
216 pub unsafe fn from_model_library_bytes(bytes: &[u8]) -> FrankaResult<Model> {
217 // SAFETY: delegated to this function's own contract.
218 let backend = unsafe { SoModelBackend::from_bytes(bytes)? };
219 Ok(Model::from_backend(Box::new(backend)))
220 }
221
222 /// Builds a model from a `libfcimodels` shared object already on disk.
223 ///
224 /// Unlike [`Model::from_model_library_bytes`] the file is left alone when
225 /// the model is dropped.
226 ///
227 /// # Safety
228 ///
229 /// The file is `dlopen`ed, which executes its code in this process. The
230 /// caller asserts that `path` names a **trusted** `libfcimodels` build for
231 /// the current platform and that it cannot be swapped before the load. See
232 /// [`SoModelBackend::open`].
233 ///
234 /// # Errors
235 ///
236 /// [`FrankaError::Model`] when the file cannot be `dlopen`ed or when any of
237 /// the thirty `libfcimodels` symbols is missing.
238 pub unsafe fn from_model_library_path(path: &Path) -> FrankaResult<Model> {
239 // SAFETY: delegated to this function's own contract.
240 let backend = unsafe { SoModelBackend::open(path)? };
241 Ok(Model::from_backend(Box::new(backend)))
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn v10_has_no_load_model_library() {
251 // A `Network` is not needed to reach the version check: it is the first thing
252 // `load_from_robot` does, and `command_id` is a pure function of the version.
253 assert!(command_id(FciVersion::V10, CommandKind::LoadModelLibrary).is_none());
254 assert_eq!(
255 command_id(FciVersion::V5, CommandKind::LoadModelLibrary),
256 Some(13)
257 );
258 }
259
260 #[test]
261 fn the_unavailable_message_matches_the_agreed_wording() {
262 let message = format!(
263 "libfranka: {} is not available on FCI version {}.",
264 CommandKind::LoadModelLibrary.name(),
265 FciVersion::V10.number()
266 );
267 assert_eq!(
268 message,
269 "libfranka: Load Model Library is not available on FCI version 10."
270 );
271 }
272
273 #[cfg(feature = "model-library")]
274 #[test]
275 fn this_build_asks_for_a_supported_platform() {
276 // The CI and development target is x86-64 Linux, which is the only pair the FER
277 // actually serves; the point of the assertion is that the mapping is wired up.
278 if cfg!(all(target_arch = "x86_64", target_os = "linux")) {
279 assert_eq!(
280 architecture().unwrap(),
281 v5::LoadModelLibraryArchitecture::X64
282 );
283 assert_eq!(system().unwrap(), v5::LoadModelLibrarySystem::Linux);
284 }
285 }
286}