refactor(firmware): centralize configuration and diagnostics
This commit is contained in:
1 parent
7048ed89d0
commit
387fdaad23
140 files changed
+6891
-4541
No files matched your search
@@ -11,7 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
kaptan-control = { path = "../../libs/kaptan-control" }
|
||||
kaptan-config = { path = "../../libs/kaptan-config" }
|
||||
kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink", features = ["host-authority"] }
|
||||
kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" }
|
||||
kaptan-flight-config = { path = "../../libs/kaptan-flight-config" }
|
||||
kaptan-flight-runtime = { path = "../../libs/kaptan-flight-runtime" }
|
||||
kaptan-log = { path = "../../libs/kaptan-log", features = ["facade"] }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small operator client for the local Kaptan host-SITL process."""
|
||||
"""Small user client for the local Kaptan host-SITL process."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,11 +14,11 @@ import time
|
||||
|
||||
CONTROL = ("127.0.0.1", 14662)
|
||||
TRUTH = ("127.0.0.1", 14663)
|
||||
# The simulator lease identifies its one local operator by UDP source address.
|
||||
# The simulator lease identifies its one local user by UDP source address.
|
||||
# Keep this source stable across separate CLI invocations so a completed move
|
||||
# can be followed by the next command without a second, competing operator.
|
||||
OPERATOR_CONTROL = ("127.0.0.1", 14665)
|
||||
OPERATOR_CLOCK = ("127.0.0.1", 14664)
|
||||
# can be followed by the next command without a second, competing user.
|
||||
USER_CONTROL = ("127.0.0.1", 14665)
|
||||
USER_CLOCK = ("127.0.0.1", 14664)
|
||||
VERSION = 1
|
||||
STATUS_QUERY = struct.Struct("<4sB3x")
|
||||
STATUS = struct.Struct("<4sB3xQH2x")
|
||||
@@ -109,7 +109,7 @@ def recv(socket_: socket.socket, deadline: float) -> bytes | None:
|
||||
def latest_simulation_time(
|
||||
clock_socket: socket.socket, previous: int | None
|
||||
) -> int | None:
|
||||
"""Drains Gazebo's test-operator clock without trusting host elapsed time."""
|
||||
"""Drains Gazebo's test-user clock without trusting host elapsed time."""
|
||||
latest = previous
|
||||
while True:
|
||||
try:
|
||||
@@ -514,13 +514,13 @@ def main() -> int:
|
||||
if tolerances:
|
||||
raise ValueError("truth tolerances and hold duration must be finite and positive")
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
|
||||
client.bind(OPERATOR_CONTROL)
|
||||
client.bind(USER_CONTROL)
|
||||
if values.command == "ready":
|
||||
wait_ready(client, values.timeout)
|
||||
print("ready")
|
||||
elif values.command == "move":
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as clock:
|
||||
clock.bind(OPERATOR_CLOCK)
|
||||
clock.bind(USER_CLOCK)
|
||||
clock.setblocking(False)
|
||||
move(
|
||||
client,
|
||||
@@ -538,7 +538,7 @@ def main() -> int:
|
||||
)
|
||||
else:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as clock:
|
||||
clock.bind(OPERATOR_CLOCK)
|
||||
clock.bind(USER_CLOCK)
|
||||
clock.setblocking(False)
|
||||
land(
|
||||
client,
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
use kaptan_flight_runtime::RuntimeLogHub;
|
||||
use kaptan_log::{FacadeIngress, GlobalLogger, LogFilter, Severity};
|
||||
use kaptan_log::{FacadeIngress, GlobalLogger, LogRecord};
|
||||
|
||||
const SITL_LOG_INGRESS_CAPACITY: usize = 16;
|
||||
static SITL_LOG_INGRESS: FacadeIngress<SITL_LOG_INGRESS_CAPACITY> =
|
||||
FacadeIngress::new(LogFilter::new(Severity::Info));
|
||||
static SITL_LOG_INGRESS: FacadeIngress<SITL_LOG_INGRESS_CAPACITY> = FacadeIngress::new();
|
||||
static SITL_LOGGER: GlobalLogger<SITL_LOG_INGRESS_CAPACITY> =
|
||||
GlobalLogger::new(&SITL_LOG_INGRESS, sitl_forbidden_log_context);
|
||||
static INSTALL_SITL_LOGGER: Once = Once::new();
|
||||
@@ -28,10 +26,7 @@ pub fn install_log_facade() {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn set_filter(filter: LogFilter) {
|
||||
SITL_LOG_INGRESS.set_filter(filter);
|
||||
}
|
||||
|
||||
pub(super) fn drain_one_into(hub: &mut RuntimeLogHub) {
|
||||
let _ = SITL_LOG_INGRESS.drain_one_into(hub);
|
||||
/// Removes one transport-neutral event for composition-level sink fanout.
|
||||
pub(super) fn take() -> Option<LogRecord> {
|
||||
SITL_LOG_INGRESS.take()
|
||||
}
|
||||
@@ -12,7 +12,7 @@ mod store;
|
||||
mod tests;
|
||||
|
||||
pub use logging::install_log_facade;
|
||||
pub use runtime::{CAPABILITIES, SitlConfigRuntime, SitlSnapshotImporter, configuration_service};
|
||||
pub use runtime::{CAPABILITIES, SitlConfigRuntime, configuration_service};
|
||||
pub use store::{
|
||||
FileProfileStore, FileStoreError, default_profile, load_profile, write_default_profile,
|
||||
FileConfigStore, FileStoreError, default_profile, load_profile, write_default_profile,
|
||||
};
|
||||
@@ -1,69 +1,45 @@
|
||||
//! Central SITL configuration and protocol composition.
|
||||
|
||||
use kaptan_config::ConfigManager;
|
||||
use kaptan_config_mavlink::{
|
||||
Authorized, ConfigCommand, ConfigProtocol, ProtocolError, ServiceResponse, SnapshotImporter,
|
||||
Target,
|
||||
};
|
||||
use kaptan_flight_config::{
|
||||
CandidateProfile, CborError, FORMAT_VERSION, VehicleCapabilities, VehicleProfile,
|
||||
decode_candidate,
|
||||
};
|
||||
use kaptan_config_mavlink::{ConfigCommand, ConfigProtocol, Principal, ServiceResponse, Target};
|
||||
use kaptan_flight_config::{CandidateProfile, VehicleCapabilities, VehicleProfile};
|
||||
use kaptan_flight_runtime::FlightRuntime;
|
||||
use kaptan_log::TransportAdmission;
|
||||
use kaptan_log_mavlink::{DiagnosticPayload, encode_diagnostic};
|
||||
|
||||
use super::{FileProfileStore, logging};
|
||||
use super::{FileConfigStore, logging};
|
||||
|
||||
/// SITL's immutable compiled capability contract.
|
||||
pub const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
|
||||
|
||||
/// Host integration adapter from the schema's one bounded CBOR profile blob to
|
||||
/// the generic custom-MAVLink transaction service.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SitlSnapshotImporter;
|
||||
|
||||
impl SnapshotImporter<CandidateProfile> for SitlSnapshotImporter {
|
||||
const IMPORT_SUPPORTED: bool = true;
|
||||
const PROFILE_FORMAT: u16 = FORMAT_VERSION;
|
||||
|
||||
fn decode(&self, bytes: &[u8]) -> Result<CandidateProfile, ProtocolError> {
|
||||
decode_candidate(bytes, &CAPABILITIES).map_err(|error| match error {
|
||||
CborError::TooLarge => ProtocolError::CborTooLarge,
|
||||
CborError::Malformed | CborError::TrailingData => ProtocolError::CborMalformed,
|
||||
CborError::InvalidProfile(violation) => ProtocolError::Violation(violation),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// SITL composition root retaining the one profile manager and one protocol
|
||||
/// lease state machine. The MAVLink adapter never owns either resource.
|
||||
pub struct SitlConfigRuntime {
|
||||
runtime: FlightRuntime<FileProfileStore>,
|
||||
protocol: ConfigProtocol<SitlSnapshotImporter>,
|
||||
runtime: FlightRuntime<FileConfigStore>,
|
||||
protocol: ConfigProtocol,
|
||||
}
|
||||
|
||||
impl SitlConfigRuntime {
|
||||
/// Dispatches one host-authorized transaction command. Real target ingress
|
||||
/// remains disabled until signing/replay authority is implemented.
|
||||
pub fn dispatch(&mut self, command: Authorized<ConfigCommand>) -> ServiceResponse {
|
||||
let (manager, _) = self.runtime.configuration_and_logs_mut();
|
||||
self.protocol.dispatch(manager, command)
|
||||
}
|
||||
|
||||
/// Enqueues the high-level error companion only after a composition owner
|
||||
/// has retained the authoritative `KAPTAN_CONFIG_RESULT`. There is no
|
||||
/// request correlation in `KAPTAN_ERROR`; its purpose is human-facing
|
||||
/// observability, never replacement of the structured result.
|
||||
pub fn after_config_result(&mut self, response: ServiceResponse) {
|
||||
if let ServiceResponse::Result(reply) = response {
|
||||
let (_, logs) = self.runtime.configuration_and_logs_mut();
|
||||
let _ = reply.publish_rejection(logs);
|
||||
}
|
||||
/// Dispatches one command for a caller-owned lease principal.
|
||||
pub fn dispatch(&mut self, input: (Principal, ConfigCommand)) -> Vec<ServiceResponse> {
|
||||
let (principal, command) = input;
|
||||
let mut responses = Vec::new();
|
||||
self.protocol
|
||||
.dispatch_from_composition(
|
||||
self.runtime.configuration_mut(),
|
||||
principal,
|
||||
command,
|
||||
|response| {
|
||||
responses.push(response);
|
||||
Ok::<(), core::convert::Infallible>(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
responses
|
||||
}
|
||||
|
||||
/// Returns the central manager for SITL state inspection only.
|
||||
pub const fn manager(&self) -> &ConfigManager<CandidateProfile, FileProfileStore> {
|
||||
pub const fn manager(&self) -> &ConfigManager<CandidateProfile, FileConfigStore> {
|
||||
self.runtime.configuration()
|
||||
}
|
||||
|
||||
@@ -72,32 +48,27 @@ impl SitlConfigRuntime {
|
||||
*self.runtime.configuration().active()
|
||||
}
|
||||
|
||||
/// Indicates custom CBOR import support to the local GCS bridge.
|
||||
pub const fn capability_flags(&self) -> u8 {
|
||||
self.protocol.capability_flags_for::<CandidateProfile>()
|
||||
}
|
||||
|
||||
/// Drains one standard `log` facade record and exposes the generated
|
||||
/// diagnostic payload for the host's sole MAVLink link owner. This
|
||||
/// host-only harness accepts the bounded handoff synchronously.
|
||||
pub fn pending_diagnostic_payload(&mut self) -> Option<DiagnosticPayload> {
|
||||
let (_, logs) = self.runtime.configuration_and_logs_mut();
|
||||
logging::drain_one_into(logs);
|
||||
if let Some(record) = logging::take() {
|
||||
self.runtime.publish_log_event(record);
|
||||
}
|
||||
self.runtime
|
||||
.submit_next(|record| (TransportAdmission::Accepted, encode_diagnostic(record)))
|
||||
.submit_next_mavlink(|record| (TransportAdmission::Accepted, encode_diagnostic(record)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the host-only central configuration composition used by forthcoming
|
||||
/// SITL/GCS integration. Flight targets must supply authenticated authority
|
||||
/// and a reviewed journal/activation coordinator before constructing one.
|
||||
pub fn configuration_service(store: FileProfileStore) -> Result<SitlConfigRuntime, String> {
|
||||
pub fn configuration_service(store: FileConfigStore) -> Result<SitlConfigRuntime, String> {
|
||||
logging::install_log_facade();
|
||||
let runtime = FlightRuntime::recover(CAPABILITIES, store)
|
||||
let runtime = FlightRuntime::recover(CandidateProfile::factory(), CAPABILITIES, store)
|
||||
.map_err(|error| format!("configuration recovery failed: {error:?}"))?;
|
||||
logging::set_filter(runtime.log_filter());
|
||||
Ok(SitlConfigRuntime {
|
||||
runtime,
|
||||
protocol: ConfigProtocol::with_importer(Target::new(1, 1), SitlSnapshotImporter),
|
||||
protocol: ConfigProtocol::new(Target::new(1, 1)),
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use kaptan_config::{Generation, ProfileStore};
|
||||
use kaptan_config::ConfigStore;
|
||||
use kaptan_flight_config::{
|
||||
CandidateProfile, CborEncodeError, CborError, MAXIMUM_CANDIDATE_BYTES, VehicleProfile,
|
||||
decode_candidate, encode_candidate,
|
||||
@@ -24,44 +24,34 @@ pub enum FileStoreError {
|
||||
Cbor(CborError),
|
||||
/// The fixed snapshot buffer could not encode the complete candidate.
|
||||
Encode,
|
||||
/// The host generation counter cannot advance.
|
||||
GenerationExhausted,
|
||||
/// Factory-only startup deliberately has no durable configuration path.
|
||||
FactoryOnly,
|
||||
/// The private host envelope was malformed or did not carry a valid
|
||||
/// nonzero committed generation.
|
||||
Envelope,
|
||||
}
|
||||
|
||||
/// Same-directory atomic-replacement store for SITL only.
|
||||
pub struct FileProfileStore {
|
||||
pub struct FileConfigStore {
|
||||
path: Option<std::path::PathBuf>,
|
||||
generation: Option<Generation>,
|
||||
}
|
||||
|
||||
impl FileProfileStore {
|
||||
impl FileConfigStore {
|
||||
/// Creates a host-only store at a fixed profile path.
|
||||
pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
|
||||
Self {
|
||||
path: Some(path.into()),
|
||||
generation: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a factory-only store for `kaptan-sitl serve` without
|
||||
/// `--config`. Commits are rejected rather than silently discarded.
|
||||
pub const fn factory_only() -> Self {
|
||||
Self {
|
||||
path: None,
|
||||
generation: None,
|
||||
}
|
||||
Self { path: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl ProfileStore<CandidateProfile> for FileProfileStore {
|
||||
impl ConfigStore<CandidateProfile> for FileConfigStore {
|
||||
type Error = FileStoreError;
|
||||
|
||||
fn load(&mut self) -> Result<Option<(Generation, CandidateProfile)>, Self::Error> {
|
||||
fn load(&mut self) -> Result<Option<CandidateProfile>, Self::Error> {
|
||||
let Some(path) = self.path.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -70,7 +60,7 @@ impl ProfileStore<CandidateProfile> for FileProfileStore {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(FileStoreError::Io(error)),
|
||||
};
|
||||
const MAXIMUM_FILE_BYTES: u64 = (MAXIMUM_CANDIDATE_BYTES + 64) as u64;
|
||||
const MAXIMUM_FILE_BYTES: u64 = MAXIMUM_CANDIDATE_BYTES as u64;
|
||||
if file.metadata().map_err(FileStoreError::Io)?.len() > MAXIMUM_FILE_BYTES {
|
||||
return Err(FileStoreError::Cbor(CborError::TooLarge));
|
||||
}
|
||||
@@ -81,35 +71,20 @@ impl ProfileStore<CandidateProfile> for FileProfileStore {
|
||||
if bytes.len() as u64 > MAXIMUM_FILE_BYTES {
|
||||
return Err(FileStoreError::Cbor(CborError::TooLarge));
|
||||
}
|
||||
let (generation, candidate) = decode_envelope(&bytes)?;
|
||||
self.generation = Some(generation);
|
||||
Ok(Some((generation, candidate)))
|
||||
decode_candidate(&bytes, &CAPABILITIES)
|
||||
.map(Some)
|
||||
.map_err(FileStoreError::Cbor)
|
||||
}
|
||||
|
||||
fn persist(&mut self, candidate: &CandidateProfile) -> Result<Generation, Self::Error> {
|
||||
fn persist(&mut self, candidate: &CandidateProfile) -> Result<(), Self::Error> {
|
||||
let path = self.path.clone().ok_or(FileStoreError::FactoryOnly)?;
|
||||
if self.generation.is_none() {
|
||||
let _ = self.load()?;
|
||||
}
|
||||
let generation = match self.generation {
|
||||
Some(generation) => Generation::try_new(
|
||||
generation
|
||||
.get()
|
||||
.checked_add(1)
|
||||
.ok_or(FileStoreError::GenerationExhausted)?,
|
||||
)
|
||||
.ok_or(FileStoreError::GenerationExhausted)?,
|
||||
None => Generation::new(1),
|
||||
};
|
||||
write_candidate(&path, *candidate, generation)?;
|
||||
self.generation = Some(generation);
|
||||
Ok(generation)
|
||||
write_candidate(&path, *candidate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the fully validated factory-active profile.
|
||||
pub fn default_profile() -> Result<VehicleProfile, String> {
|
||||
Ok(configuration_service(FileProfileStore::factory_only())?.active_profile())
|
||||
Ok(configuration_service(FileConfigStore::factory_only())?.active_profile())
|
||||
}
|
||||
|
||||
/// Loads one bounded, complete CBOR candidate and returns its active view.
|
||||
@@ -117,23 +92,18 @@ pub fn load_profile(path: impl AsRef<Path>) -> Result<VehicleProfile, String> {
|
||||
if !path.as_ref().is_file() {
|
||||
return Err("configuration file does not exist".to_owned());
|
||||
}
|
||||
Ok(configuration_service(FileProfileStore::new(path.as_ref()))?.active_profile())
|
||||
Ok(configuration_service(FileConfigStore::new(path.as_ref()))?.active_profile())
|
||||
}
|
||||
|
||||
/// Writes one validated factory candidate using a same-directory replacement.
|
||||
pub fn write_default_profile(path: impl AsRef<Path>) -> Result<(), String> {
|
||||
let mut store = FileProfileStore::new(path.as_ref());
|
||||
let mut store = FileConfigStore::new(path.as_ref());
|
||||
store
|
||||
.persist(&CandidateProfile::default())
|
||||
.map(|_| ())
|
||||
.persist(&CandidateProfile::factory())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn write_candidate(
|
||||
path: &Path,
|
||||
candidate: CandidateProfile,
|
||||
generation: Generation,
|
||||
) -> Result<(), FileStoreError> {
|
||||
fn write_candidate(path: &Path, candidate: CandidateProfile) -> Result<(), FileStoreError> {
|
||||
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||
encode_candidate(candidate, &CAPABILITIES, &mut writer).map_err(|error| match error {
|
||||
CborEncodeError::InvalidProfile(violation) => {
|
||||
@@ -143,35 +113,10 @@ fn write_candidate(
|
||||
})?;
|
||||
let length = writer.position();
|
||||
let bytes = writer.into_inner();
|
||||
let mut envelope = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES + 64]);
|
||||
let mut encoder = minicbor::Encoder::new(&mut envelope);
|
||||
encoder
|
||||
.array(2)
|
||||
.and_then(|encoder| encoder.u64(generation.get()))
|
||||
.and_then(|encoder| encoder.bytes(&bytes[..length]))
|
||||
.map_err(|_| FileStoreError::Encode)?;
|
||||
let envelope_length = encoder.writer_mut().position();
|
||||
let envelope = envelope.into_inner();
|
||||
let temporary = path.with_extension("cbor.tmp");
|
||||
let mut file = File::create(&temporary).map_err(FileStoreError::Io)?;
|
||||
file.write_all(&envelope[..envelope_length])
|
||||
file.write_all(&bytes[..length])
|
||||
.map_err(FileStoreError::Io)?;
|
||||
file.sync_all().map_err(FileStoreError::Io)?;
|
||||
fs::rename(&temporary, path).map_err(FileStoreError::Io)
|
||||
}
|
||||
|
||||
fn decode_envelope(bytes: &[u8]) -> Result<(Generation, CandidateProfile), FileStoreError> {
|
||||
let mut decoder = minicbor::Decoder::new(bytes);
|
||||
if decoder.array().map_err(|_| FileStoreError::Envelope)? != Some(2) {
|
||||
return Err(FileStoreError::Envelope);
|
||||
}
|
||||
let generation = Generation::try_new(decoder.u64().map_err(|_| FileStoreError::Envelope)?)
|
||||
.ok_or(FileStoreError::Envelope)?;
|
||||
let candidate_bytes = decoder.bytes().map_err(|_| FileStoreError::Envelope)?;
|
||||
if decoder.position() != bytes.len() {
|
||||
return Err(FileStoreError::Envelope);
|
||||
}
|
||||
let candidate =
|
||||
decode_candidate(candidate_bytes, &CAPABILITIES).map_err(FileStoreError::Cbor)?;
|
||||
Ok((generation, candidate))
|
||||
}
|
||||
@@ -1,68 +1,14 @@
|
||||
use std::fs;
|
||||
|
||||
use kaptan_config::{ConfigModel, Generation, ProfileStore};
|
||||
use kaptan_config_mavlink::{
|
||||
Authorized, ConfigCommand, ResultKind, ServiceResponse, SnapshotImporter,
|
||||
};
|
||||
use kaptan_flight_config::{
|
||||
CandidateProfile, CborError, MAXIMUM_CANDIDATE_BYTES, SCHEMA_ID as PROFILE_SCHEMA_ID,
|
||||
decode_candidate, encode_candidate,
|
||||
};
|
||||
use kaptan_config::{ConfigModel, ConfigStore};
|
||||
use kaptan_flight_config::{CandidateProfile, decode_candidate};
|
||||
use kaptan_log_mavlink::DiagnosticPayload;
|
||||
use kaptan_mavlink::{
|
||||
encoder::MavlinkEncoder,
|
||||
message::{Message, kaptan::MavMessage},
|
||||
};
|
||||
|
||||
use super::{CAPABILITIES, FileProfileStore, SitlSnapshotImporter, configuration_service};
|
||||
|
||||
// Independently authored current-format CBOR map. It is intentionally not
|
||||
// produced by `encode_candidate`: this is the GCS/upload compatibility vector
|
||||
// for schema KAPTANPROFILE-01, with a non-default 1.7 kg mass.
|
||||
const EXTERNAL_PROFILE_CBOR: &[u8] = &[
|
||||
0xb8, 0x1a, // definite map, 26 entries
|
||||
0x00, 0x01, // format key/value
|
||||
0x19, 0xff, 0xff, 0x50, b'K', b'A', b'P', b'T', b'A', b'N', b'P', b'R', b'O', b'F', b'I', b'L',
|
||||
b'E', b'-', b'0', b'1', // schema key/value
|
||||
0x01, 0x1a, 0x00, 0x01, 0x86, 0xa0, // prearm dwell: 100000
|
||||
0x02, 0x19, 0xc3, 0x50, // IMU interval: 50000
|
||||
0x03, 0x19, 0xc3, 0x50, // IMU freshness: 50000
|
||||
0x04, 0x1a, 0x00, 0x02, 0x49, 0xf0, // local freshness: 150000
|
||||
0x05, 0x1a, 0x00, 0x07, 0xa1, 0x20, // link freshness: 500000
|
||||
0x06, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x07, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x08, 0xfa, 0x3f, 0x80,
|
||||
0x00, 0x00, 0x09, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0a, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0b, 0xfa,
|
||||
0x3f, 0x80, 0x00, 0x00, 0x0c, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0d, 0xfa, 0x3f, 0xd9, 0x99,
|
||||
0x9a, // mass: 1.7
|
||||
0x0e, 0xfa, 0x41, 0xa0, 0x00, 0x00, // collective thrust: 20
|
||||
0x0f, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x10, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x11, 0xfa, 0x3f, 0x80,
|
||||
0x00, 0x00, 0x12, 0xfa, 0x00, 0x00, 0x00, 0x00, 0x13, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x14, 0xfa,
|
||||
0x3f, 0x80, 0x00, 0x00, 0x15, 0xfa, 0x00, 0x00, 0x00, 0x00, 0x16, 0xfa, 0x3f, 0x80, 0x00, 0x00,
|
||||
0x17, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x18, 0x18, 0x06, // maximum log level: INFO
|
||||
];
|
||||
|
||||
fn result(response: ServiceResponse) -> kaptan_config_mavlink::ConfigReply {
|
||||
match response {
|
||||
ServiceResponse::Result(reply) => reply,
|
||||
ServiceResponse::Info(_) | ServiceResponse::Descriptors(_) | ServiceResponse::Value(_) => {
|
||||
panic!("expected a mutating configuration result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn crc32c(bytes: &[u8]) -> u32 {
|
||||
let mut crc = !0_u32;
|
||||
for byte in bytes {
|
||||
crc ^= u32::from(*byte);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 0 {
|
||||
crc >> 1
|
||||
} else {
|
||||
(crc >> 1) ^ 0x82f6_3b78
|
||||
};
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
use super::{CAPABILITIES, FileConfigStore, configuration_service};
|
||||
|
||||
#[test]
|
||||
fn host_store_round_trips_a_complete_validated_snapshot() {
|
||||
@@ -75,216 +21,24 @@ fn host_store_round_trips_a_complete_validated_snapshot() {
|
||||
let _ = fs::remove_file(&path);
|
||||
let _ = fs::remove_file(&temporary);
|
||||
|
||||
let mut store = FileProfileStore::new(&path);
|
||||
let mut store = FileConfigStore::new(&path);
|
||||
assert!(store.load().unwrap().is_none());
|
||||
assert_eq!(
|
||||
store.persist(&CandidateProfile::default()).unwrap(),
|
||||
Generation::new(1)
|
||||
);
|
||||
let envelope = fs::read(&path).unwrap();
|
||||
assert!(matches!(
|
||||
decode_candidate(&envelope, &CAPABILITIES),
|
||||
Err(CborError::Malformed)
|
||||
));
|
||||
let (_, candidate) = store.load().unwrap().expect("persisted candidate");
|
||||
store.persist(&CandidateProfile::factory()).unwrap();
|
||||
let snapshot = fs::read(&path).unwrap();
|
||||
let decoded = decode_candidate(&snapshot, &CAPABILITIES).unwrap();
|
||||
assert!(decoded.validate(&CAPABILITIES).is_ok());
|
||||
let candidate = store.load().unwrap().expect("persisted candidate");
|
||||
assert!(candidate.validate(&CAPABILITIES).is_ok());
|
||||
|
||||
assert_eq!(
|
||||
store.persist(&CandidateProfile::default()).unwrap(),
|
||||
Generation::new(2)
|
||||
);
|
||||
let mut reopened = FileProfileStore::new(&path);
|
||||
assert_eq!(
|
||||
reopened.load().unwrap().map(|(generation, _)| generation),
|
||||
Some(Generation::new(2))
|
||||
);
|
||||
let mut fresh = FileProfileStore::new(&path);
|
||||
assert_eq!(
|
||||
fresh.persist(&CandidateProfile::default()).unwrap(),
|
||||
Generation::new(3)
|
||||
);
|
||||
store.persist(&CandidateProfile::factory()).unwrap();
|
||||
let mut reopened = FileConfigStore::new(&path);
|
||||
assert!(reopened.load().unwrap().is_some());
|
||||
let mut fresh = FileConfigStore::new(&path);
|
||||
fresh.persist(&CandidateProfile::factory()).unwrap();
|
||||
|
||||
fs::remove_file(&path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sitl_snapshot_importer_uses_the_real_schema_and_capabilities() {
|
||||
let candidate = CandidateProfile::default();
|
||||
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||
encode_candidate(candidate, &CAPABILITIES, &mut writer).unwrap();
|
||||
let length = writer.position();
|
||||
let bytes = writer.into_inner();
|
||||
let imported = SitlSnapshotImporter
|
||||
.decode(&bytes[..length])
|
||||
.expect("valid current candidate import");
|
||||
assert!(imported.validate(&CAPABILITIES).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sitl_service_imports_a_real_cbor_candidate_only_into_its_transaction() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"kaptan-config-import-{}-{}.cbor",
|
||||
std::process::id(),
|
||||
std::thread::current().name().unwrap_or("test")
|
||||
));
|
||||
let _ = fs::remove_file(&path);
|
||||
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
|
||||
let begin = result(service.dispatch(Authorized::host(
|
||||
9,
|
||||
ConfigCommand::Begin {
|
||||
request_id: 1,
|
||||
expected_generation: 0,
|
||||
target: kaptan_config_mavlink::Target::new(1, 1),
|
||||
},
|
||||
)));
|
||||
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||
encode_candidate(CandidateProfile::default(), &CAPABILITIES, &mut writer).unwrap();
|
||||
let length = writer.position();
|
||||
assert!(length <= 192);
|
||||
let bytes = writer.into_inner();
|
||||
let import_begin = result(service.dispatch(Authorized::host(
|
||||
9,
|
||||
ConfigCommand::ImportBegin {
|
||||
request_id: 2,
|
||||
session_id: begin.session_id,
|
||||
expected_revision: 0,
|
||||
length: length as u16,
|
||||
schema_id: PROFILE_SCHEMA_ID,
|
||||
crc32c: crc32c(&bytes[..length]),
|
||||
target: kaptan_config_mavlink::Target::new(1, 1),
|
||||
},
|
||||
)));
|
||||
assert_eq!(import_begin.result, ResultKind::Accepted);
|
||||
let mut chunk = [0; 192];
|
||||
chunk[..length].copy_from_slice(&bytes[..length]);
|
||||
assert_eq!(
|
||||
result(service.dispatch(Authorized::host(
|
||||
9,
|
||||
ConfigCommand::ImportChunk {
|
||||
request_id: 3,
|
||||
session_id: begin.session_id,
|
||||
transfer_id: import_begin.transfer_id,
|
||||
offset: 0,
|
||||
length: length as u8,
|
||||
data: chunk,
|
||||
target: kaptan_config_mavlink::Target::new(1, 1),
|
||||
},
|
||||
)))
|
||||
.result,
|
||||
ResultKind::Accepted
|
||||
);
|
||||
assert_eq!(
|
||||
result(service.dispatch(Authorized::host(
|
||||
9,
|
||||
ConfigCommand::ImportFinish {
|
||||
request_id: 4,
|
||||
session_id: begin.session_id,
|
||||
transfer_id: import_begin.transfer_id,
|
||||
expected_revision: 0,
|
||||
target: kaptan_config_mavlink::Target::new(1, 1),
|
||||
},
|
||||
)))
|
||||
.result,
|
||||
ResultKind::Accepted
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.manager()
|
||||
.transaction_value(kaptan_config::FieldId::new(1))
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.manager()
|
||||
.active_value(kaptan_config::FieldId::new(1))
|
||||
.is_some()
|
||||
);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_gcs_vector_imports_commits_and_reopens_as_the_boot_profile() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"kaptan-config-gcs-vector-{}-{}.cbor",
|
||||
std::process::id(),
|
||||
std::thread::current().name().unwrap_or("test")
|
||||
));
|
||||
let _ = fs::remove_file(&path);
|
||||
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
|
||||
let target = kaptan_config_mavlink::Target::new(1, 1);
|
||||
let begin = result(service.dispatch(Authorized::host(
|
||||
9,
|
||||
ConfigCommand::Begin {
|
||||
request_id: 1,
|
||||
expected_generation: 0,
|
||||
target,
|
||||
},
|
||||
)));
|
||||
let import = result(service.dispatch(Authorized::host(
|
||||
9,
|
||||
ConfigCommand::ImportBegin {
|
||||
request_id: 2,
|
||||
session_id: begin.session_id,
|
||||
expected_revision: 0,
|
||||
length: EXTERNAL_PROFILE_CBOR.len() as u16,
|
||||
schema_id: PROFILE_SCHEMA_ID,
|
||||
crc32c: crc32c(EXTERNAL_PROFILE_CBOR),
|
||||
target,
|
||||
},
|
||||
)));
|
||||
let mut data = [0_u8; 192];
|
||||
data[..EXTERNAL_PROFILE_CBOR.len()].copy_from_slice(EXTERNAL_PROFILE_CBOR);
|
||||
assert_eq!(
|
||||
result(service.dispatch(Authorized::host(
|
||||
9,
|
||||
ConfigCommand::ImportChunk {
|
||||
request_id: 3,
|
||||
session_id: begin.session_id,
|
||||
transfer_id: import.transfer_id,
|
||||
offset: 0,
|
||||
length: EXTERNAL_PROFILE_CBOR.len() as u8,
|
||||
data,
|
||||
target,
|
||||
},
|
||||
)))
|
||||
.result,
|
||||
ResultKind::Accepted
|
||||
);
|
||||
assert_eq!(
|
||||
result(service.dispatch(Authorized::host(
|
||||
9,
|
||||
ConfigCommand::ImportFinish {
|
||||
request_id: 4,
|
||||
session_id: begin.session_id,
|
||||
transfer_id: import.transfer_id,
|
||||
expected_revision: 0,
|
||||
target,
|
||||
},
|
||||
)))
|
||||
.revision,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
result(service.dispatch(Authorized::host(
|
||||
9,
|
||||
ConfigCommand::Commit {
|
||||
request_id: 5,
|
||||
session_id: begin.session_id,
|
||||
expected_generation: 0,
|
||||
expected_revision: 1,
|
||||
target,
|
||||
},
|
||||
)))
|
||||
.result,
|
||||
ResultKind::CommittedRestartRequired
|
||||
);
|
||||
drop(service);
|
||||
|
||||
let reopened = configuration_service(FileProfileStore::new(&path)).unwrap();
|
||||
assert_eq!(reopened.active_profile().thrust().mass_kg(), 1.7);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_log_macro_reaches_the_generated_mavlink_log_payload() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
@@ -293,13 +47,13 @@ fn standard_log_macro_reaches_the_generated_mavlink_log_payload() {
|
||||
std::thread::current().name().unwrap_or("test")
|
||||
));
|
||||
let _ = fs::remove_file(&path);
|
||||
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
|
||||
let mut service = configuration_service(FileConfigStore::new(&path)).unwrap();
|
||||
|
||||
log::info!("SITL configuration service ready");
|
||||
let Some(DiagnosticPayload::Log(payload)) = service.pending_diagnostic_payload() else {
|
||||
panic!("info facade record must reach KAPTAN_LOG");
|
||||
};
|
||||
assert_eq!(payload.level, kaptan_log::Severity::Info as u8);
|
||||
assert_eq!(payload.level, kaptan_log::Severity::Info.wire_value());
|
||||
let message = std::str::from_utf8(&payload.message[..usize::from(payload.message_length)])
|
||||
.expect("facade text remains UTF-8");
|
||||
assert!(message.contains("config::tests"));
|
||||
@@ -320,37 +74,3 @@ fn standard_log_macro_reaches_the_generated_mavlink_log_payload() {
|
||||
));
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_config_result_enqueues_a_separate_high_level_error_payload() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"kaptan-sitl-config-error-{}-{}.cbor",
|
||||
std::process::id(),
|
||||
std::thread::current().name().unwrap_or("test")
|
||||
));
|
||||
let _ = fs::remove_file(&path);
|
||||
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
|
||||
let response = service.dispatch(Authorized::host(
|
||||
9,
|
||||
ConfigCommand::Begin {
|
||||
request_id: 1,
|
||||
expected_generation: 1,
|
||||
target: kaptan_config_mavlink::Target::new(1, 1),
|
||||
},
|
||||
));
|
||||
let reply = result(response);
|
||||
assert_eq!(reply.result, ResultKind::Rejected);
|
||||
service.after_config_result(response);
|
||||
|
||||
let Some(DiagnosticPayload::Error(payload)) = service.pending_diagnostic_payload() else {
|
||||
panic!("rejected configuration result must enqueue KAPTAN_ERROR");
|
||||
};
|
||||
let error_path = std::str::from_utf8(&payload.path[..usize::from(payload.path_length)])
|
||||
.expect("derived error path remains UTF-8");
|
||||
assert_eq!(
|
||||
error_path,
|
||||
"kaptan_config_mavlink::protocol::ProtocolError::GenerationConflict"
|
||||
);
|
||||
assert_eq!(payload.additional_information_length, 0);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
@@ -273,7 +273,7 @@ class Quad final : public gz::sim::System,
|
||||
std::uint16_t sensor_port{};
|
||||
std::uint16_t actuator_port{};
|
||||
std::uint16_t truth_port{};
|
||||
std::uint16_t operator_clock_port{};
|
||||
std::uint16_t user_clock_port{};
|
||||
std::chrono::microseconds actuator_timeout{};
|
||||
double maximum_motor_thrust_newtons{};
|
||||
double motor_time_constant_seconds{};
|
||||
@@ -325,8 +325,8 @@ void Quad::Configure(const gz::sim::Entity &entity,
|
||||
const unsigned int actuator_port =
|
||||
sdf->Get<unsigned int>("actuator_udp_port", 0U).first;
|
||||
const unsigned int truth_port = sdf->Get<unsigned int>("truth_udp_port", 0U).first;
|
||||
const unsigned int operator_clock_port =
|
||||
sdf->Get<unsigned int>("operator_clock_udp_port", 0U).first;
|
||||
const unsigned int user_clock_port =
|
||||
sdf->Get<unsigned int>("user_clock_udp_port", 0U).first;
|
||||
const std::uint64_t timeout_usec =
|
||||
sdf->Get<std::uint64_t>("actuator_timeout_usec", 0).first;
|
||||
this->data_->maximum_motor_thrust_newtons =
|
||||
@@ -342,11 +342,11 @@ void Quad::Configure(const gz::sim::Entity &entity,
|
||||
this->data_->reaction_torque_per_newton_metre =
|
||||
sdf->Get<double>("reaction_torque_per_newton_metre", 0.0).first;
|
||||
if (imu_topic.empty() || sensor_port == 0 || actuator_port == 0 || truth_port == 0 ||
|
||||
operator_clock_port == 0 ||
|
||||
user_clock_port == 0 ||
|
||||
sensor_port > std::numeric_limits<std::uint16_t>::max() ||
|
||||
actuator_port > std::numeric_limits<std::uint16_t>::max() ||
|
||||
truth_port > std::numeric_limits<std::uint16_t>::max() ||
|
||||
operator_clock_port > std::numeric_limits<std::uint16_t>::max() ||
|
||||
user_clock_port > std::numeric_limits<std::uint16_t>::max() ||
|
||||
timeout_usec == 0 || !std::isfinite(this->data_->maximum_motor_thrust_newtons) ||
|
||||
this->data_->maximum_motor_thrust_newtons <= 0.0 ||
|
||||
!std::isfinite(this->data_->motor_time_constant_seconds) ||
|
||||
@@ -362,7 +362,7 @@ void Quad::Configure(const gz::sim::Entity &entity,
|
||||
this->data_->sensor_port = static_cast<std::uint16_t>(sensor_port);
|
||||
this->data_->actuator_port = static_cast<std::uint16_t>(actuator_port);
|
||||
this->data_->truth_port = static_cast<std::uint16_t>(truth_port);
|
||||
this->data_->operator_clock_port = static_cast<std::uint16_t>(operator_clock_port);
|
||||
this->data_->user_clock_port = static_cast<std::uint16_t>(user_clock_port);
|
||||
this->data_->actuator_timeout = std::chrono::microseconds(timeout_usec);
|
||||
this->data_->local_position_noise =
|
||||
std::normal_distribution<double>(0.0, local_position_noise_stddev);
|
||||
@@ -482,7 +482,7 @@ void Quad::PostUpdate(const gz::sim::UpdateInfo &info,
|
||||
(void)this->data_->socket.SendToLoopback(
|
||||
this->data_->sensor_port, clock_packet.data(), clock_packet.size());
|
||||
(void)this->data_->socket.SendToLoopback(
|
||||
this->data_->operator_clock_port, clock_packet.data(), clock_packet.size());
|
||||
this->data_->user_clock_port, clock_packet.data(), clock_packet.size());
|
||||
};
|
||||
gz::msgs::IMU imu;
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ use kaptan_time::Timestamp;
|
||||
#[cfg(test)]
|
||||
use crate::config::default_profile;
|
||||
use crate::config::{
|
||||
FileProfileStore, SitlConfigRuntime, configuration_service, load_profile, write_default_profile,
|
||||
FileConfigStore, SitlConfigRuntime, configuration_service, load_profile, write_default_profile,
|
||||
};
|
||||
use kaptan_flight_config::VehicleProfile;
|
||||
|
||||
@@ -86,8 +86,8 @@ fn serve_configuration(
|
||||
arguments: &mut impl Iterator<Item = String>,
|
||||
) -> Result<SitlConfigRuntime, String> {
|
||||
match (arguments.next().as_deref(), arguments.next()) {
|
||||
(None, None) => configuration_service(FileProfileStore::factory_only()),
|
||||
(Some("--config"), Some(path)) => configuration_service(FileProfileStore::new(path)),
|
||||
(None, None) => configuration_service(FileConfigStore::factory_only()),
|
||||
(Some("--config"), Some(path)) => configuration_service(FileConfigStore::new(path)),
|
||||
_ => Err("usage: kaptan-sitl serve [--config PROFILE.cbor]".to_owned()),
|
||||
}
|
||||
}
|
||||
@@ -122,10 +122,8 @@ fn serve(configuration: SitlConfigRuntime) -> Result<(), String> {
|
||||
.set_nonblocking(true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut runtime = SitlRuntime::from_configuration(configuration)?;
|
||||
let config_generation = runtime.configuration.manager().active_generation();
|
||||
eprintln!(
|
||||
"kaptan-sitl: configuration generation {} selected at boot; listening for Gazebo sensors on 127.0.0.1:{SENSOR_PORT} and commands on 127.0.0.1:{CONTROL_PORT}",
|
||||
config_generation.map_or(0, kaptan_config::Generation::get),
|
||||
"kaptan-sitl: boot configuration selected; listening for Gazebo sensors on 127.0.0.1:{SENSOR_PORT} and commands on 127.0.0.1:{CONTROL_PORT}",
|
||||
);
|
||||
|
||||
loop {
|
||||
@@ -190,9 +188,6 @@ impl DatagramSink for UdpSocket {
|
||||
}
|
||||
|
||||
struct SitlRuntime {
|
||||
/// Retains the sole central manager that selected this boot-active profile.
|
||||
/// No live apply path exists: a commit is restart-required.
|
||||
configuration: SitlConfigRuntime,
|
||||
profile: VehicleProfile,
|
||||
initializer: StationaryInitializer<100>,
|
||||
estimator: Option<InertialEskf>,
|
||||
@@ -224,13 +219,12 @@ enum ClockWork {
|
||||
impl SitlRuntime {
|
||||
#[cfg(test)]
|
||||
fn new() -> Result<Self, String> {
|
||||
Self::from_configuration(configuration_service(FileProfileStore::factory_only())?)
|
||||
Self::from_configuration(configuration_service(FileConfigStore::factory_only())?)
|
||||
}
|
||||
|
||||
fn from_configuration(configuration: SitlConfigRuntime) -> Result<Self, String> {
|
||||
let profile = configuration.active_profile();
|
||||
Ok(Self {
|
||||
configuration,
|
||||
profile,
|
||||
initializer: StationaryInitializer::new(),
|
||||
estimator: None,
|
||||
@@ -1071,7 +1065,7 @@ fn strictly_after(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use core::cell::RefCell;
|
||||
use kaptan_config::{ConfigModel, ConfigValue, FieldId, ProfileStore};
|
||||
use kaptan_config::{ConfigModel, ConfigStore, ConfigValue, FieldId};
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingSink {
|
||||
@@ -1148,11 +1142,11 @@ mod tests {
|
||||
std::thread::current().name().unwrap_or("test")
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut candidate = kaptan_flight_config::CandidateProfile::default();
|
||||
let mut candidate = kaptan_flight_config::CandidateProfile::factory();
|
||||
candidate
|
||||
.set_field(FieldId::new(13), ConfigValue::F32(1.7))
|
||||
.unwrap();
|
||||
FileProfileStore::new(&path).persist(&candidate).unwrap();
|
||||
FileConfigStore::new(&path).persist(&candidate).unwrap();
|
||||
|
||||
let mut arguments =
|
||||
vec!["--config".to_owned(), path.to_string_lossy().into_owned()].into_iter();
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
<sensor_udp_port>14660</sensor_udp_port>
|
||||
<actuator_udp_port>14661</actuator_udp_port>
|
||||
<truth_udp_port>14663</truth_udp_port>
|
||||
<operator_clock_udp_port>14664</operator_clock_udp_port>
|
||||
<user_clock_udp_port>14664</user_clock_udp_port>
|
||||
<actuator_timeout_usec>25000</actuator_timeout_usec>
|
||||
<maximum_motor_thrust_newtons>4.5</maximum_motor_thrust_newtons>
|
||||
<motor_time_constant_seconds>0.03</motor_time_constant_seconds>
|
||||
|
||||
Reference in new issue
Block a user