Skip to main content

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}