// Deterministic, deliberately narrow Gazebo-side adapter for the Kaptan // quad-X host-SITL transport contract. It is simulator tooling, not flight code. #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace kaptan::sitl { namespace { constexpr std::uint8_t kPacketVersion = 1; constexpr std::array kImuMagic{'K', 'S', 'I', '1'}; constexpr std::array kLocalStateMagic{'K', 'S', 'V', '1'}; constexpr std::array kClockMagic{'K', 'S', 'C', '1'}; constexpr std::array kTruthMagic{'K', 'S', 'T', '2'}; constexpr std::array kOutputMagic{'K', 'S', 'O', '1'}; constexpr std::size_t kImuPacketSize = 44; constexpr std::size_t kClockPacketSize = 16; constexpr std::size_t kTruthPacketSize = 76; constexpr std::size_t kOutputPacketSize = 40; constexpr std::size_t kMaximumPacketsPerStep = 4; constexpr std::size_t kQuadMotorCount = 4; struct QuadMotorGeometry { gz::math::Vector3d offset_flu; double reaction_torque_sign; }; // This test model is FLU / ENU. It is not an electrical Kakute timer map: // M1 front-right CW, M2 rear-right CCW, M3 rear-left CW, M4 front-left CCW. constexpr std::array kQuadXGeometry{{ {{0.17, -0.17, 0.0}, -1.0}, {{-0.17, -0.17, 0.0}, 1.0}, {{-0.17, 0.17, 0.0}, -1.0}, {{0.17, 0.17, 0.0}, 1.0}, }}; bool IsFiniteFraction(const float value) { return std::isfinite(value) && value >= 0.0F && value <= 1.0F; } bool IsZero(const float value) { return value == 0.0F; } void WriteU32(std::uint8_t *destination, const std::uint32_t value) { for (std::size_t index = 0; index < sizeof(value); ++index) { destination[index] = static_cast(value >> (index * 8)); } } void WriteU64(std::uint8_t *destination, const std::uint64_t value) { for (std::size_t index = 0; index < sizeof(value); ++index) { destination[index] = static_cast(value >> (index * 8)); } } std::uint32_t ReadU32(const std::uint8_t *source) { std::uint32_t value = 0; for (std::size_t index = 0; index < sizeof(value); ++index) { value |= static_cast(source[index]) << (index * 8); } return value; } std::uint64_t ReadU64(const std::uint8_t *source) { std::uint64_t value = 0; for (std::size_t index = 0; index < sizeof(value); ++index) { value |= static_cast(source[index]) << (index * 8); } return value; } void WriteF32(std::uint8_t *destination, const float value) { WriteU32(destination, std::bit_cast(value)); } float ReadF32(const std::uint8_t *source) { return std::bit_cast(ReadU32(source)); } bool SequenceAfter(const std::uint32_t next, const std::uint32_t previous) { const std::uint32_t distance = next - previous; return distance != 0 && distance <= UINT32_MAX / 2; } class UdpSocket { public: UdpSocket() = default; ~UdpSocket() { if (this->descriptor_ >= 0) { close(this->descriptor_); } } UdpSocket(const UdpSocket &) = delete; UdpSocket &operator=(const UdpSocket &) = delete; bool BindLoopback(const std::uint16_t port) { this->descriptor_ = socket(AF_INET, SOCK_DGRAM, 0); if (this->descriptor_ < 0) { return false; } const int flags = fcntl(this->descriptor_, F_GETFL, 0); if (flags < 0 || fcntl(this->descriptor_, F_SETFL, flags | O_NONBLOCK) < 0) { return false; } sockaddr_in address{}; address.sin_family = AF_INET; address.sin_port = htons(port); address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); return bind( this->descriptor_, reinterpret_cast(&address), sizeof(address)) == 0; } bool SendToLoopback( const std::uint16_t port, const std::uint8_t *data, const std::size_t size) const { sockaddr_in address{}; address.sin_family = AF_INET; address.sin_port = htons(port); address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); return sendto( this->descriptor_, data, size, MSG_DONTWAIT, reinterpret_cast(&address), sizeof(address)) == static_cast(size); } ssize_t Receive(std::uint8_t *data, const std::size_t size) const { sockaddr_in sender{}; socklen_t sender_size = sizeof(sender); const ssize_t received = recvfrom( this->descriptor_, data, size, MSG_DONTWAIT, reinterpret_cast(&sender), &sender_size); if (received < 0 || sender.sin_addr.s_addr != htonl(INADDR_LOOPBACK)) { return -1; } return received; } private: int descriptor_{-1}; }; struct OutputCommand { std::uint64_t controller_time_usec{}; std::uint32_t control_sequence{}; bool permitted{}; std::array motors{}; }; bool DecodeOutput(const std::array &packet, OutputCommand &output) { if (!std::equal(kOutputMagic.begin(), kOutputMagic.end(), packet.begin()) || packet[4] != kPacketVersion || packet[5] != 0 || packet[6] != 0 || packet[7] != 0) { return false; } output.controller_time_usec = ReadU64(packet.data() + 8); output.control_sequence = ReadU32(packet.data() + 16); if (packet[20] > 1 || packet[21] != 0 || packet[22] != 0 || packet[23] != 0) { return false; } output.permitted = packet[20] == 1; for (std::size_t index = 0; index < output.motors.size(); ++index) { output.motors[index] = ReadF32(packet.data() + 24 + index * sizeof(float)); if (!IsFiniteFraction(output.motors[index])) { return false; } } return output.permitted || std::all_of(output.motors.begin(), output.motors.end(), IsZero); } } // namespace class Quad final : public gz::sim::System, public gz::sim::ISystemConfigure, public gz::sim::ISystemPreUpdate, public gz::sim::ISystemPostUpdate, public gz::sim::ISystemReset { public: Quad() : data_(std::make_unique()) {} void Configure(const gz::sim::Entity &entity, const std::shared_ptr &sdf, gz::sim::EntityComponentManager &ecm, gz::sim::EventManager &) override; void PreUpdate(const gz::sim::UpdateInfo &info, gz::sim::EntityComponentManager &ecm) override; void PostUpdate(const gz::sim::UpdateInfo &info, const gz::sim::EntityComponentManager &) override; void Reset(const gz::sim::UpdateInfo &, gz::sim::EntityComponentManager &) override; private: struct Data { void OnImu(const gz::msgs::IMU &message) { std::lock_guard lock(this->imu_mutex); this->latest_imu = message; ++this->imu_generation; if (!this->received_first_imu) { gzmsg << "Kaptan SITL quad received its first Gazebo IMU sample.\n"; this->received_first_imu = true; } } gz::sim::Link body; gz::transport::Node node; UdpSocket socket; std::uint16_t sensor_port{}; std::uint16_t actuator_port{}; std::uint16_t truth_port{}; std::uint16_t operator_clock_port{}; std::chrono::microseconds actuator_timeout{}; double maximum_motor_thrust_newtons{}; double motor_time_constant_seconds{}; std::normal_distribution local_position_noise{0.0, 0.0}; std::normal_distribution local_velocity_noise{0.0, 0.0}; std::mt19937 local_state_random{}; double reaction_torque_per_newton_metre{}; std::optional launch_origin_enu; std::optional launch_attitude_enu; std::mutex imu_mutex; gz::msgs::IMU latest_imu; std::uint64_t imu_generation{}; std::uint64_t published_imu_generation{}; bool received_first_imu{}; std::optional output; std::array applied_motor_fractions{}; std::optional> previous_output_order; // Actuator freshness belongs to the simulated vehicle timeline, never // host scheduling time. That keeps timeout behavior deterministic when // Gazebo is paused, stepped, or runs at a non-real-time factor. std::optional last_output_sim_time; }; std::unique_ptr data_; }; void Quad::Configure(const gz::sim::Entity &entity, const std::shared_ptr &sdf, gz::sim::EntityComponentManager &ecm, gz::sim::EventManager &) { const gz::sim::Model model(entity); if (!model.Valid(ecm)) { gzerr << "Kaptan SITL quad must be attached to a model.\n"; return; } const auto body_entity = model.LinkByName(ecm, "base_link"); if (body_entity == gz::sim::kNullEntity) { gzerr << "Kaptan SITL quad requires a base_link.\n"; return; } this->data_->body = gz::sim::Link(body_entity); this->data_->body.EnableVelocityChecks(ecm); const std::string imu_topic = sdf->Get("imu_topic", "").first; const unsigned int sensor_port = sdf->Get("sensor_udp_port", 0U).first; const unsigned int actuator_port = sdf->Get("actuator_udp_port", 0U).first; const unsigned int truth_port = sdf->Get("truth_udp_port", 0U).first; const unsigned int operator_clock_port = sdf->Get("operator_clock_udp_port", 0U).first; const std::uint64_t timeout_usec = sdf->Get("actuator_timeout_usec", 0).first; this->data_->maximum_motor_thrust_newtons = sdf->Get("maximum_motor_thrust_newtons", 0.0).first; this->data_->motor_time_constant_seconds = sdf->Get("motor_time_constant_seconds", 0.0).first; const double local_position_noise_stddev = sdf->Get("local_position_noise_stddev_m", -1.0).first; const double local_velocity_noise_stddev = sdf->Get("local_velocity_noise_stddev_mps", -1.0).first; const unsigned int local_state_noise_seed = sdf->Get("local_state_noise_seed", 0U).first; this->data_->reaction_torque_per_newton_metre = sdf->Get("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 || sensor_port > std::numeric_limits::max() || actuator_port > std::numeric_limits::max() || truth_port > std::numeric_limits::max() || operator_clock_port > std::numeric_limits::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) || this->data_->motor_time_constant_seconds <= 0.0 || !std::isfinite(local_position_noise_stddev) || local_position_noise_stddev < 0.0 || !std::isfinite(local_velocity_noise_stddev) || local_velocity_noise_stddev < 0.0 || !std::isfinite(this->data_->reaction_torque_per_newton_metre) || this->data_->reaction_torque_per_newton_metre < 0.0) { gzerr << "Kaptan SITL quad has an invalid static transport or vehicle configuration.\n"; return; } this->data_->sensor_port = static_cast(sensor_port); this->data_->actuator_port = static_cast(actuator_port); this->data_->truth_port = static_cast(truth_port); this->data_->operator_clock_port = static_cast(operator_clock_port); this->data_->actuator_timeout = std::chrono::microseconds(timeout_usec); this->data_->local_position_noise = std::normal_distribution(0.0, local_position_noise_stddev); this->data_->local_velocity_noise = std::normal_distribution(0.0, local_velocity_noise_stddev); this->data_->local_state_random.seed(local_state_noise_seed); if (!this->data_->socket.BindLoopback(this->data_->actuator_port)) { gzerr << "Kaptan SITL quad could not bind its loopback actuator socket.\n"; return; } if (!this->data_->node.Subscribe(imu_topic, &Data::OnImu, this->data_.get())) { gzerr << "Kaptan SITL quad could not subscribe to its configured IMU topic.\n"; } else { gzmsg << "Kaptan SITL quad subscribed to Gazebo IMU topic [" << imu_topic << "].\n"; } } void Quad::PreUpdate(const gz::sim::UpdateInfo &info, gz::sim::EntityComponentManager &ecm) { if (info.paused || info.simTime.count() < 0 || !this->data_->body.Valid(ecm)) { return; } const auto simulation_time = std::chrono::duration_cast(info.simTime); for (std::size_t attempt = 0; attempt < kMaximumPacketsPerStep; ++attempt) { std::array packet{}; if (this->data_->socket.Receive(packet.data(), packet.size()) != static_cast(packet.size())) { break; } OutputCommand command{}; if (!DecodeOutput(packet, command)) { continue; } if (!command.permitted) { this->data_->output = command; continue; } if (this->data_->previous_output_order.has_value() && (command.controller_time_usec <= this->data_->previous_output_order->first || !SequenceAfter(command.control_sequence, this->data_->previous_output_order->second))) { continue; } this->data_->previous_output_order = std::pair{command.controller_time_usec, command.control_sequence}; this->data_->last_output_sim_time = simulation_time; this->data_->output = command; } const bool command_is_fresh = this->data_->output.has_value() && this->data_->output->permitted && this->data_->last_output_sim_time.has_value() && simulation_time >= *this->data_->last_output_sim_time && simulation_time - *this->data_->last_output_sim_time <= this->data_->actuator_timeout; const auto pose = this->data_->body.WorldPose(ecm); if (!pose.has_value()) { return; } const double dt_seconds = std::chrono::duration(info.dt).count(); if (!std::isfinite(dt_seconds) || dt_seconds <= 0.0) { return; } const double alpha = 1.0 - std::exp(-dt_seconds / this->data_->motor_time_constant_seconds); const auto desired_motors = command_is_fresh ? this->data_->output->motors : std::array{}; const gz::math::Vector3d thrust_axis_world = pose->Rot().RotateVector({0.0, 0.0, 1.0}); for (std::size_t index = 0; index < kQuadMotorCount; ++index) { this->data_->applied_motor_fractions[index] += alpha * (static_cast(desired_motors[index]) - this->data_->applied_motor_fractions[index]); const double thrust = this->data_->applied_motor_fractions[index] * this->data_->maximum_motor_thrust_newtons; this->data_->body.AddWorldWrench( ecm, thrust_axis_world * thrust, thrust_axis_world * (kQuadXGeometry[index].reaction_torque_sign * thrust * this->data_->reaction_torque_per_newton_metre), kQuadXGeometry[index].offset_flu); } } void Quad::PostUpdate(const gz::sim::UpdateInfo &info, const gz::sim::EntityComponentManager &_ecm) { if (info.paused || info.simTime.count() < 0) { return; } // This is deliberately emitted for every unpaused Gazebo step, even when // an IMU sample is absent. The host runtime uses it as its sole freshness // and lease time base; host scheduling cannot advance safety state. const auto send_clock = [this, &info]() { std::array clock_packet{}; std::copy(kClockMagic.begin(), kClockMagic.end(), clock_packet.begin()); clock_packet[4] = kPacketVersion; WriteU64(clock_packet.data() + 8, static_cast( std::chrono::duration_cast( info.simTime) .count())); (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()); }; gz::msgs::IMU imu; { std::lock_guard lock(this->data_->imu_mutex); if (this->data_->imu_generation == this->data_->published_imu_generation) { send_clock(); return; } imu = this->data_->latest_imu; this->data_->published_imu_generation = this->data_->imu_generation; } // Gazebo's standard body sensor convention is FLU. Kaptan's is FRD, so the // single explicit boundary conversion is x, -y, -z for both vectors. std::array packet{}; std::copy(kImuMagic.begin(), kImuMagic.end(), packet.begin()); packet[4] = kPacketVersion; WriteU64(packet.data() + 8, static_cast( std::chrono::duration_cast( info.simTime) .count())); WriteU32(packet.data() + 16, static_cast(this->data_->imu_generation)); const std::array frd{ static_cast(imu.linear_acceleration().x()), -static_cast(imu.linear_acceleration().y()), -static_cast(imu.linear_acceleration().z()), static_cast(imu.angular_velocity().x()), -static_cast(imu.angular_velocity().y()), -static_cast(imu.angular_velocity().z()), }; if (!std::all_of(frd.begin(), frd.end(), [](const float value) { return std::isfinite(value); })) { send_clock(); return; } for (std::size_t index = 0; index < frd.size(); ++index) { WriteF32(packet.data() + 20 + index * sizeof(float), frd[index]); } (void)this->data_->socket.SendToLoopback( this->data_->sensor_port, packet.data(), packet.size()); const auto pose = this->data_->body.WorldPose(_ecm); const auto velocity = this->data_->body.WorldLinearVelocity(_ecm); if (!pose.has_value() || !velocity.has_value()) { send_clock(); return; } if (!this->data_->launch_origin_enu.has_value()) { this->data_->launch_origin_enu = pose->Pos(); this->data_->launch_attitude_enu = pose->Rot(); } // This fixed test vehicle is yawed so ENU world +Y/+X/-Z are respectively // its launch-local NED north/east/down axes. The conversion happens once at // the Gazebo boundary; all flight code receives only explicit NED SI data. const auto &origin = *this->data_->launch_origin_enu; const std::array local_ned{ static_cast(pose->Pos().Y() - origin.Y() + this->data_->local_position_noise(this->data_->local_state_random)), static_cast(pose->Pos().X() - origin.X() + this->data_->local_position_noise(this->data_->local_state_random)), static_cast(origin.Z() - pose->Pos().Z() + this->data_->local_position_noise(this->data_->local_state_random)), static_cast(velocity->Y() + this->data_->local_velocity_noise(this->data_->local_state_random)), static_cast(velocity->X() + this->data_->local_velocity_noise(this->data_->local_state_random)), static_cast(-velocity->Z() + this->data_->local_velocity_noise(this->data_->local_state_random)), }; if (!std::all_of( local_ned.begin(), local_ned.end(), [](const float value) { return std::isfinite(value); })) { send_clock(); return; } std::array state_packet{}; std::copy(kLocalStateMagic.begin(), kLocalStateMagic.end(), state_packet.begin()); state_packet[4] = kPacketVersion; WriteU64(state_packet.data() + 8, static_cast( std::chrono::duration_cast( info.simTime) .count())); WriteU32(state_packet.data() + 16, static_cast(this->data_->imu_generation)); for (std::size_t index = 0; index < local_ned.size(); ++index) { WriteF32(state_packet.data() + 20 + index * sizeof(float), local_ned[index]); } (void)this->data_->socket.SendToLoopback( this->data_->sensor_port, state_packet.data(), state_packet.size()); // This packet is intentionally sent to a separate test-only port. The // controller never binds that port, so these noise-free values cannot form a // parallel control path or mask estimator behavior. The quaternion is the // body attitude relative to launch; its rotation magnitude is frame // invariant, which permits the test client to verify attitude without // duplicating the ENU/FLU-to-NED/FRD conversion path. const auto launch_attitude = *this->data_->launch_attitude_enu; const auto relative_attitude = launch_attitude.Inverse() * pose->Rot(); const std::array truth_ned{ static_cast(pose->Pos().Y() - origin.Y()), static_cast(pose->Pos().X() - origin.X()), static_cast(origin.Z() - pose->Pos().Z()), static_cast(velocity->Y()), static_cast(velocity->X()), static_cast(-velocity->Z()), static_cast(relative_attitude.W()), static_cast(relative_attitude.X()), static_cast(relative_attitude.Y()), static_cast(relative_attitude.Z()), static_cast(this->data_->applied_motor_fractions[0]), static_cast(this->data_->applied_motor_fractions[1]), static_cast(this->data_->applied_motor_fractions[2]), static_cast(this->data_->applied_motor_fractions[3]), }; if (!std::all_of( truth_ned.begin(), truth_ned.end(), [](const float value) { return std::isfinite(value); })) { send_clock(); return; } std::array truth_packet{}; std::copy(kTruthMagic.begin(), kTruthMagic.end(), truth_packet.begin()); truth_packet[4] = kPacketVersion; WriteU64(truth_packet.data() + 8, static_cast( std::chrono::duration_cast( info.simTime) .count())); WriteU32(truth_packet.data() + 16, static_cast(this->data_->imu_generation)); for (std::size_t index = 0; index < truth_ned.size(); ++index) { WriteF32(truth_packet.data() + 20 + index * sizeof(float), truth_ned[index]); } (void)this->data_->socket.SendToLoopback( this->data_->truth_port, truth_packet.data(), truth_packet.size()); send_clock(); } void Quad::Reset(const gz::sim::UpdateInfo &, gz::sim::EntityComponentManager &) { this->data_->output.reset(); this->data_->applied_motor_fractions = {}; this->data_->previous_output_order.reset(); this->data_->last_output_sim_time.reset(); this->data_->launch_origin_enu.reset(); this->data_->launch_attitude_enu.reset(); std::lock_guard lock(this->data_->imu_mutex); this->data_->published_imu_generation = this->data_->imu_generation; } } // namespace kaptan::sitl GZ_ADD_PLUGIN(kaptan::sitl::Quad, gz::sim::System, kaptan::sitl::Quad::ISystemConfigure, kaptan::sitl::Quad::ISystemPreUpdate, kaptan::sitl::Quad::ISystemPostUpdate, kaptan::sitl::Quad::ISystemReset) GZ_ADD_PLUGIN_ALIAS(kaptan::sitl::Quad, "kaptan::sitl::Quad")