From 1d64b959808689eb45f239840a5b8f377798b39c Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Wed, 8 Apr 2026 15:51:52 +0200 Subject: [PATCH 001/119] added imu_bridge node Signed-off-by: Peter Geurts --- .devcontainer/dev/Dockerfile | 2 + .../alliander_utilities/config_objects.py | 15 +- .../src/joystick_manager.cpp | 3 - alliander_xsens/alliander_xsens.Dockerfile | 43 +++ alliander_xsens/docker-compose.yml | 18 ++ .../src/alliander_xsens/CMakeLists.txt | 43 +++ .../config/xsens_mti_node.yaml | 267 ++++++++++++++++++ .../alliander_xsens/include/imu_bridge.hpp | 47 +++ .../alliander_xsens/launch/xsens.launch.py | 107 +++++++ .../src/alliander_xsens/package.xml | 30 ++ .../src/alliander_xsens/src/imu_bridge.cpp | 33 +++ .../src/alliander_xsens/src/main.cpp | 12 + components.yml | 4 + predefined_configurations.py | 5 + pyproject.toml | 4 + uv.lock | 15 + 16 files changed, 644 insertions(+), 4 deletions(-) create mode 100644 alliander_xsens/alliander_xsens.Dockerfile create mode 100644 alliander_xsens/docker-compose.yml create mode 100644 alliander_xsens/src/alliander_xsens/CMakeLists.txt create mode 100644 alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml create mode 100644 alliander_xsens/src/alliander_xsens/include/imu_bridge.hpp create mode 100644 alliander_xsens/src/alliander_xsens/launch/xsens.launch.py create mode 100644 alliander_xsens/src/alliander_xsens/package.xml create mode 100644 alliander_xsens/src/alliander_xsens/src/imu_bridge.cpp create mode 100644 alliander_xsens/src/alliander_xsens/src/main.cpp diff --git a/.devcontainer/dev/Dockerfile b/.devcontainer/dev/Dockerfile index f3f1e91f7..533ee4aa9 100644 --- a/.devcontainer/dev/Dockerfile +++ b/.devcontainer/dev/Dockerfile @@ -18,6 +18,7 @@ RUN apt update && apt install -y --no-install-recommends \ # Install MoveIt: RUN apt update && apt install -y --no-install-recommends \ + ros-$ROS_DISTRO-imu-filter-madgwick \ ros-$ROS_DISTRO-moveit \ ros-$ROS_DISTRO-moveit-servo \ ros-$ROS_DISTRO-moveit-visual-tools \ @@ -53,6 +54,7 @@ COPY alliander_franka/src/ /alliander/ros/src COPY alliander_joystick/src/ /alliander/ros/src COPY alliander_moveit/src/ /alliander/ros/src COPY alliander_nav2/src/ /alliander/ros/src +COPY alliander_xsens/src/ /alliander/ros/src # Build repo packages: RUN /alliander/colcon_build.sh diff --git a/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py b/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py index 22f4888a7..96a03bec5 100644 --- a/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py +++ b/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py @@ -331,6 +331,19 @@ class GPS(Platform): diagnostic_timeouts: tuple[int, int, int] = (3, 5, 10) +@dataclass +class Imu(Platform): + """Configuration for an Imu platform. + + Attributes: + platform_type (str): Type identifier for the platform. + usb_device (str): USB device path, e.g. /dev/imu. + """ + + platform_type: str = "IMU" + usb_device: str = "/dev/imu" + + # Configurations containing lists of platforms: @dataclass class PlatformList(Config): @@ -342,7 +355,7 @@ class PlatformList(Config): platforms: List[ Annotated[ - Union[Platform, Arm, Vehicle, Lidar, Camera, GPS], + Union[Platform, Arm, Vehicle, Camera, GPS, Imu, Lidar], Discriminator(field="platform_type", include_supertypes=True), ] ] = field(default_factory=list) diff --git a/alliander_joystick/src/alliander_joystick/src/joystick_manager.cpp b/alliander_joystick/src/alliander_joystick/src/joystick_manager.cpp index 3914e6aa7..9e488bcbc 100644 --- a/alliander_joystick/src/alliander_joystick/src/joystick_manager.cpp +++ b/alliander_joystick/src/alliander_joystick/src/joystick_manager.cpp @@ -200,9 +200,6 @@ bool JoystickManager::check_btn_pressed(size_t idx, void JoystickManager::handle_driving(const float& linear, const float& angular) { - float prev_linear = prev_joy_input->axes[1]; - float prev_angular = prev_joy_input->axes[2]; - geometry_msgs::msg::TwistStamped twist; twist.header.stamp = node->now(); twist.header.frame_id = "base_link"; diff --git a/alliander_xsens/alliander_xsens.Dockerfile b/alliander_xsens/alliander_xsens.Dockerfile new file mode 100644 index 000000000..4d1681698 --- /dev/null +++ b/alliander_xsens/alliander_xsens.Dockerfile @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +ARG BASE_IMAGE=ubuntu:latest +FROM $BASE_IMAGE + +ARG COLCON_BUILD_SEQUENTIAL +ENV ROS_DISTRO=jazzy + +# Install ROS dependencies +RUN apt update && apt install -y --no-install-recommends \ + ros-$ROS_DISTRO-imu-filter-madgwick \ + ros-$ROS_DISTRO-nmea-msgs \ + ros-$ROS_DISTRO-mavros-msgs \ + && rm -rf /var/lib/apt/lists/* \ + && apt autoremove -y \ + && apt clean + +# Install Xsens package: +WORKDIR /$WORKDIR/external +RUN apt update \ + && git clone -b ros2 https://github.com/xsenssupport/Xsens_MTi_ROS_Driver_and_Ntrip_Client.git src/xsens \ + && rosdep update --rosdistro $ROS_DISTRO \ + && rosdep install --from-paths src -y -i +RUN /$WORKDIR/colcon_build.sh + +# Install repo packages: +WORKDIR /$WORKDIR/ros +COPY alliander_core/src/ /$WORKDIR/ros/src +COPY alliander_xsens/src/ /$WORKDIR/ros/src +RUN /$WORKDIR/colcon_build.sh + +# Install python dependencies: +WORKDIR $WORKDIR +COPY pyproject.toml /$WORKDIR/pyproject.toml +RUN uv sync \ + && echo "export PYTHONPATH=\"$(dirname $(dirname $(uv python find)))/lib/python3.12/site-packages:\$PYTHONPATH\"" >> /root/.bashrc \ + && echo "export PATH=\"$(dirname $(dirname $(uv python find)))/bin:\$PATH\"" >> /root/.bashrc + +# Finalize +WORKDIR /$WORKDIR +ENTRYPOINT ["/entrypoint.sh"] +CMD ["sleep", "infinity"] diff --git a/alliander_xsens/docker-compose.yml b/alliander_xsens/docker-compose.yml new file mode 100644 index 000000000..ace9f5379 --- /dev/null +++ b/alliander_xsens/docker-compose.yml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +services: + alliander_xsens: + image: allianderrobotics/xsens + container_name: alliander_xsens + runtime: nvidia + network_mode: host + privileged: true + mem_limit: 6gb + tty: true + env_file: + - .env + volumes: + - "/tmp/.X11-unix:/tmp/.X11-unix" + - "/dev:/dev" + command: ["/bin/bash", "-c", "ros2 launch alliander_xsens xsens.launch.py"] diff --git a/alliander_xsens/src/alliander_xsens/CMakeLists.txt b/alliander_xsens/src/alliander_xsens/CMakeLists.txt new file mode 100644 index 000000000..99b70fd1e --- /dev/null +++ b/alliander_xsens/src/alliander_xsens/CMakeLists.txt @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.5) +project(alliander_xsens) + +# CMake dependencies: +find_package(ament_cmake REQUIRED) +find_package(ament_cmake_python REQUIRED) +find_package(geometry_msgs REQUIRED) +find_package(rclcpp REQUIRED) +find_package(sensor_msgs REQUIRED) + +# C++ executables: +include_directories(include) +add_executable(imu_bridge_node + src/main.cpp + src/imu_bridge.cpp +) +ament_target_dependencies(imu_bridge_node + rclcpp + geometry_msgs + sensor_msgs +) +install( + TARGETS imu_bridge_node + DESTINATION lib/${PROJECT_NAME} +) + +# Shared folders: +install( + DIRECTORY launch + DESTINATION share/${PROJECT_NAME} +) + +# Default test: +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + ament_lint_auto_find_test_dependencies() +endif() + +ament_package() diff --git a/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml new file mode 100644 index 000000000..5064189d6 --- /dev/null +++ b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml @@ -0,0 +1,267 @@ +/**: + ros__parameters: + # Device Scanning Configuration + # ----------------------------- + # This section allows you to configure how the ROS driver searches for devices. + # You have two options: + # 1. Automatic scanning for devices. + # 2. Manual specification of port and baudrate. + + # Option 1: Automatic Device Scanning + # If set to 'true', the driver will ignore 'port' and 'baudrate' settings. + # It will automatically scan all ports and select the first device found. + scan_for_devices: true + + # Option 2: Manual Device Configuration + # If you prefer to manually specify the device port and baudrate, + # first set 'scan_for_devices' to 'false', then uncomment and update + # the 'port' and 'baudrate' settings below according to your device's specifications. + # + # To find your device's port, you can use the command: ls -l /dev/ttyUSB* + # + # Default baudrate is 115200. You can also use external tools like MT Manager + # or Cutecom for baudrate configuration. For example, + # to set baudrate to 115200, send the command: FA FF 30 00 D1, + # or to set it to 921600, send: FA FF 18 01 80 68. + + # port: '/dev/ttyUSB0' # Uncomment and set your device's port. + # baudrate: 921600 # Uncomment and set your device's baudrate. + + # Device Scanning Selection + # ---------------- + # Specify the device ID when operating multiple devices to select a specific one. + # Format: Uppercase hexadecimal string. + # Example: device_id: '00800210C2' + #device_id: '00800210C2' + + # Publisher Configuration + publisher_queue_size: 5 + + # Time Options + # ------------ + # Choose how timestamps are generated for the ROS Node: + # 1) 0: Use UTCTime from MTI, recommended for accurate time synchronization. + # 2) 1: Use UTCTime based on the SampleTimeFine from MTI. + # 3) 2: Use UTCTime from the host device. + # Note: Ensure "UTC Time" or "Sample Time Fine" is selected in MT Manager under Device Settings > Output Configurations, + # or you could also set 'enable_deviceConfig: true' for once to configure output. + time_option: 0 + + # Logging Configuration + # --------------------- + # Enable or disable logging of sensor data into .mtb file. If enabled, logs are stored in /home/[user_name]/Documents/xsens_log. + enable_logging: false + + # Transform Frame ID + # ------------------ + # Default frame_id is "imu_link". Change this if using multiple devices to avoid conflicts. + # frame_id: "imu_link" + + + # Sensor Configuration + # --------------------------- + # If you want to configure your sensor, firstly set this flag to true, then change desired values for the parameters below. + # Note: This configuration is required only once, once it is successful, the configurations are stored in the sensor, then you could set this to false. + enable_deviceConfig: false + + # Sensor Extended Kalman Filter Option Configurations: + # -------------------------- + # ref: https://base.movella.com/s/article/article/MTi-Filter-Profiles-1605869708823 + # If you want to configure the filter option, firstly set the 'enable_filter_config', then: + # For MTi-3/7/8/30/300/G-710/670(G)/680(G): change the 'mti_filter_option' to desired index below for your model. + # For MTi-620(R)/MTi-630(R), and Sirius/Avior VRU/AHRS models: change the 'filter_label_rollpitch' and 'filter_label_yaw' to the exact labels. + enable_filter_config: false + # --------------------------- + # For MTi-670(G): 3 options: 0 = "General", 1 = "GeneralNoBaro", 2 = "GeneralMag" + # For MTi-680(G), 3 options: 0 = "General_RTK", 1 = "GeneralNoBaro_RTK", 2 = "GeneralMag_RTK" + # For MTi-3: 4 options: 0 = "general", 1 = "high_mag_dep", 2 = "dynamic", 3 = "north_reference", 4 = "vru_general" + # For MTi-320: 1 option: 0 = "vru_general" + # For MTi-7: 3 options: 0 = "General", 1 = "GeneralMag", 2 = "GeneralNoBaro" + # For MTi-8: 3 options: 0 = "General_RTK", 1 = "GeneralNoBaro_RTK", 2 = "GeneralMag_RTK" + # For MTi-300: 0 = "general", 1 = "high_mag_dep", 2 = "dynamic", 3 = "lwo_mag_dep", 4 = "vru_general" + # For MTI-G-710: 3 options: 0 = "General", 1 = "GeneralNoBaro", 2 = "GeneralMag", 3 = "Automotive", 4 = "HighPerformanceEDR" + mti_filter_option: 0 + + # For MTi-620(R)/MTi-630(R), and Sirius/Avior VRU/AHRS models: roll pitch options: "Responsive", "General", "Robust" + filter_label_rollpitch: "Robust" + # MTI-620(R) and Sirius/Avior VRU models, yaw has 2 options: "VRU", "VRUAHS" + # MTI-630(R) and Sirius/Avior AHRS models, yaw has 3 options: "FixedMagRef", "NorthReference", "VRU", "VRUAHS" + filter_label_yaw: "NorthReference" + + + # MTi-2/320/200: filter only has VRU, you could enable the AHS options: + # valid for MTI-2/3/320, MTi-20/30, MTI-200/300 when using the VRU filter options. + enable_active_heading_stabilization: false + + + # GNSS Lever Arm Configuration + # ----------------------------------------------- + # Note: Adjust the GNSS lever arm settings only if the antenna's position relative to the sensor has changed. + + # GNSS Lever Arm (meters): [X, Y, Z] + # Applicable to: MTi-8/680(G) + # Default: [0.0, 0.0, 0.0] for unchanged antenna position. + GNSS_LeverArm: [0.0, 0.0, 0.0] + + # u-blox GNSS Platform Settings + # ----------------------------- + # Options: + # 0 = Portable + # 2 = Stationary + # 3 = Pedestrian + # 4 = Automotive + # 5 = AtSea + # 6 = Airborne < 1g + # 7 = Airborne < 2g + # 8 = Airborne < 4g + # 9 = Wrist + # Applicable to: MTi-7/8/670(G)/680(G)/G-710 (GNSS/INS versions). + ublox_platform: 0 + + # u-blox GNSS Receiver Settings + # ----------------------------- + # If set true: Enables Beidou, disables GLONASS + # If set false: Disables Beidou, enables GLONASS + # valid for MTi-7/8/670/680/710 + enable_beidou: false + + + # Message Publishers, Also the output configurations if enable_deviceConfig is set to true + # ------------------ + # Enable or disable publication of various sensor data: + pub_utctime: true + pub_sampletime: true + pub_imu: true + pub_quaternion: true + pub_euler: true + pub_free_acceleration: true + pub_angular_velocity: true + pub_acceleration: true + pub_dq: false + pub_dv: false + pub_mag: true + pub_temperature: true + pub_pressure: true + pub_accelerationhr: false #High Rate Data Acceleration, when this is true, set enable_high_rate to true as well + pub_angular_velocity_hr: false #High Rate Data Angular Velocity, when this is true, set enable_high_rate to true as well + pub_transform: true + pub_status: true + pub_twist: true # For GNSS/INS Version of MTI only + pub_gnss: true # For GNSS/INS Version of MTI only + pub_positionLLA: true # For GNSS/INS Version of MTI only + pub_velocity: true # For GNSS/INS Version of MTI only + pub_nmea: true # For GNSS/INS Version of MTI only + pub_gnsspose: true # For GNSS/INS Version of MTI only + pub_odometry: false # For GNSS/INS Version of MTI only + pub_euler_stddev: false # For Sirius and Avior AHRS/VRU only, this is for setouput config, and value will be published at /imu/data topic. + pub_ship_motion: true # For Sirius and Avior AHRS Only + + + + # Output Data Rate + # ---------------- + # Set the desired output data rate in Hz. The driver will automatically configure the device to match this rate. + # options for MTi-610/620/630/670/680/710/Sirius/Avior quaternion/euler/temperature/dq/dv/rateofturn/acc/free_acc, and for Sirius/Avior EulerStdDev: + #1, 2, 4, 5, 8, 10, 16, 20, 25, 40, 50, 80, 100, 200, 400 + # options for MTi-670/680/710 position/velocity: 1, 2, 4, 5, 8, 10, 16, 20, 25, 40, 50, 80, 100, 200, 400 + # Change 'output_data_rate' if you are using mti-600 or 100 series + output_data_rate: 100 + # options for MTi-1/2/3/7/8: 1, 2, 4, 5, 10, 20, 25, 50, 100 + # options for MTi-600, 710, Sirius, Avior series: mag, baro pressure, max 100Hz:1, 2, 4, 5, 10, 20, 25, 50, 100 + # options for MTi-7/8 series: baro pressure, max 50Hz:1, 2, 5, 10, 25, 50 + # Change 'output_data_rate_lower' if you are using mti-1 series or if you want to change output rate for mag, baro for other product series + # Change 'output_data_rate_lower' if you want to change the output rate for heave position and heave period + output_data_rate_lower: 100 + # Change 'output_data_rate_baro_mtione' if you use mti-7/8 only. + output_data_rate_baro_mtione: 50 + + # High Rate Data for AccelerationHR and RateOfTurn: + # ------------------------------------------------ + # if you want to enable high rate data, firstly change the 'enable_high_rate' to true, then change the data rate according to your sensor model. + enable_high_rate: false + #options for MTi-1 v2: 800, 400, 200, 160 + #options for MTi-1 v3: 1000, 500, 250, 200, 125 + #options for MTi-600/Sirius/Avior: 2000, 1000, 500 + #option for MTi-100/200/300: 1000 + output_data_rate_acchr: 1000 + #options for MTi-1 v2: 800, 400, 200, 160 + #options for MTi-1 v3: 1000, 500, 250, 200, 125 + #options for MTI-600: 1600, 800 + #option for MTi-100/200/300: 1000 + #option for Sirius/Avior: 2000, 1000, 500 + output_date_rate_gyrohr: 1000 + # whether using high rate acceleration data(accelerationHR) to interpolate the orientation and rateofturnHR data. + # when this is set to true, output_data_rate_acchr must >= output_date_rate_gyrohr + interpolate_orientation_high_rate: false + + # Filtering Option Flags + # ------------------------------------- + enable_orientation_smoother: true #valid for MTi-G-710, MTi-670(G), MTi-680(G) + enable_position_velocity_smoother: true #valid for MTi-680(G) only + enable_continuous_zero_rotation_update: true #valid for MTi-680(G) only + + # Other option flags: + # valid for MTi-2/3/7/8/20/30/620/630/670/680/200/300/710/Sirius/Avior + enable_inrun_compass_calibration: false + + + # Set baudrate + # --------------- + #This is necessary if you have hig output rate, and if you see error message of 'data overflow' + enable_setting_baudrate: false + #options: 115200, 230400, 460800, 921600, 2000000 + set_baudrate_value: 2000000 + #options for MTi-600/Sirius/Avior to enable/disable hardware flow control + port_config_hardware_flow_control: true + + + # Manual Gyro Bias Estimation (MGBE) Configuration + # ------------------------------------------------ + + # Enable or disable manual gyro bias estimation. + # If enabled, specify the 'event_interval' and 'duration' in 'manual_gyro_bias_param'. + enable_manual_gyro_bias: false + + # Parameters for manual gyro bias estimation: + # - 'event_interval': Time in seconds between two consecutive invocations. + # - 'duration': Time in seconds for which the MGBE process executes each time. + # The minimum value for 'event_interval' is 10 seconds, for 'duration' is 2 seconds. + manual_gyro_bias_param: [15, 5] # [event_interval, duration] + + # Monitoring MGBE Success: + # To verify if MGBE was successful, use 'rostopic echo /status'. + # If the 'no_rotation_update_status' changes from 3 to 0, MGBE was successful. + + # RotSensor Frame changes + # roll, pitch, yaw values + # if your MTi-630 is installed upside-down(connector to the upside), then use 180deg for the roll. + enable_rotsensor_frame_config: false + rotsensor_rotation_euler: [180.0, 0.0, 0.0] + + + # Sensor Standard Deviation (Optional) + # ------------------------------------ + # Override the covariance matrix in sensor_msgs/Imu and sensor_msgs/MagneticField messages: + linear_acceleration_stddev: [0.0, 0.0, 0.0] # [m/s^2] Standard deviation for linear acceleration + angular_velocity_stddev: [0.0, 0.0, 0.0] # [rad/s] Standard deviation for angular velocity + magnetic_field_stddev: [0.0, 0.0, 0.0] # [Tesla] Standard deviation for magnetic field + # [rad] Standard deviation for orientation, + # for Sirius/Avior, if the pub_euler_stddev is true, will use that value from the sensor and igore this value. + orientation_stddev: [0.0, 0.0, 0.0] + + + + + + + + ##Popular Xbus command + # 1) GoToConfig: FA FF 30 00 D1, GoToMeasurement: FA FF 10 00 F1 + # 2) SetBaudrate: FA FF 18 01 80 68 (921k6) or FA FF 18 01 02 E6 (115k2) + # 3) Set Filter Profile for MTi-680(G) with GeneralMag_RTK: FA FF 64 0E 47 65 6E 65 72 61 6C 4D 61 67 5F 52 54 4B 6C + # 4) RestoreFactoryDef: FA FF 0E 00 F3 + # 5) SetOutputConfiguration + ## 5.1) MTi-670(G)/680(G)/MTi-G-710, 400Hz: FA FF C0 34 10 20 FF FF 10 60 FF FF 10 10 FF FF 20 10 01 90 40 20 01 90 80 20 01 90 C0 20 00 64 08 10 01 90 E0 20 FF FF 50 42 01 90 50 22 01 90 D0 12 01 90 70 10 FF FF 6E + ## 5.2) MTi-7/8/670(G)/680(G)/MTi-G-710, 100Hz: FA FF C0 34 10 20 FF FF 10 60 FF FF 10 10 FF FF 20 10 00 64 40 20 00 64 80 20 00 64 C0 20 00 64 08 10 00 64 E0 20 FF FF 50 42 00 64 50 22 00 64 D0 12 00 64 70 10 FF FF A9 + ## 5.3) MTi-630(R)/MTi-300, 400Hz: FA FF C0 24 10 20 FF FF 10 60 FF FF 10 10 FF FF 20 10 01 90 40 20 01 90 80 20 01 90 C0 20 00 64 08 10 01 90 E0 20 FF FF 95 + ## 5.4) MTi-3/630(R)/MTi-300, 100Hz: FA FF C0 24 10 20 FF FF 10 60 FF FF 10 10 FF FF 20 10 00 64 40 20 00 64 80 20 00 64 C0 20 00 64 08 10 00 64 E0 20 FF FF 49 diff --git a/alliander_xsens/src/alliander_xsens/include/imu_bridge.hpp b/alliander_xsens/src/alliander_xsens/include/imu_bridge.hpp new file mode 100644 index 000000000..e61b1bf6b --- /dev/null +++ b/alliander_xsens/src/alliander_xsens/include/imu_bridge.hpp @@ -0,0 +1,47 @@ +// # SPDX-FileCopyrightText: Alliander N. V. +// +// # SPDX-License-Identifier: Apache-2.0 + +#ifndef IMU_BRIDGE_HPP_ +#define IMU_BRIDGE_HPP_ + +#include +#include + +#include "geometry_msgs/msg/vector3_stamped.hpp" +#include "sensor_msgs/msg/imu.hpp" + +/// Class to publish Imu messages from acceleration/angular_velocity messages. +class ImuBridge : public rclcpp::Node { + public: + /** + * @brief constructor for the ImuBridge class. + * @param node The ROS2 node to attach to. + */ + ImuBridge(); + ~ImuBridge() = default; + + private: + // ROS2 communication variables: + /// The ROS2 node + rclcpp::Node::SharedPtr node; + /// Subsciber for the acceleration topic + rclcpp::Subscription::SharedPtr sub_accel; + /// Subscriber for the angular velocity topic + rclcpp::Subscription::SharedPtr + sub_ang_vel; + /// Publisher for the IMU topic + rclcpp::Publisher::SharedPtr pub_imu; + /// Timer for publishing IMU data + rclcpp::TimerBase::SharedPtr timer_imu; + + /// Latest acceleration message + geometry_msgs::msg::Vector3Stamped latest_accel_msg; + /// Latest ang_vel message + geometry_msgs::msg::Vector3Stamped latest_ang_vel_msg; + + /// Publish IMU message + void publish_imu(); +}; + +#endif // IMU_BRIDGE_HPP_ diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py new file mode 100644 index 000000000..194ae564f --- /dev/null +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 + +from alliander_utilities.config_objects import Imu +from alliander_utilities.launch_argument import LaunchArgument +from alliander_utilities.launch_utils import SKIP, state_publisher_node, static_tf_node +from alliander_utilities.register import Register, RegisteredLaunchDescription +from alliander_utilities.ros_utils import get_file_path +from launch import LaunchContext, LaunchDescription +from launch.actions import OpaqueFunction +from launch_ros.actions import Node +from serial.tools import list_ports + +platform_arg = LaunchArgument("platform_config", "") + + +def launch_setup(context: LaunchContext) -> list: + """The launch setup. + + Args: + context (LaunchContext): The launch context. + + Returns: + list: The actions to start. + """ + imu_config = Imu.from_str(platform_arg.string_value(context)) + + VID = "2639" + PID = "0301" + imu_device = None + for device in list_ports.grep(f"{VID}:{PID}"): + print(f"Found IMU device {device}") + if imu_device is not None: + print(f"Found multiple IMU devices with VID:PID {VID}:{PID}!") + imu_device = device.name + + if imu_device is None: + print("No IMU device found, exiting.") + exit(1) + imu_config.usb_device = imu_device + + # state_publisher = state_publisher_node( + # namespace=imu_config.namespace, + # platform="xsens", + # xacro="xsens.urdf.xacro", + # xacro_arguments={ + # "parent": "" if imu_config.parent.link else "world", + # }, + # ) + # + # parent = imu_config.parent + # static_tf = static_tf_node( + # parent_frame=f"{parent.namespace}/{parent.link}" if parent.link else "map", + # child_frame=f"{imu_config.namespace}/{parent.connects_to}", + # position=imu_config.position, + # orientation=imu_config.orientation, + # ) + + hardware = Node( + package="xsens_mti_ros2_driver", + executable="xsens_mti_node", + remappings=[ + ("/imu/acceleration", "imu/acceleration"), + ("/imu/angular_velocity", "imu/angular_velocity"), + ], + namespace=imu_config.namespace, + ) + + imu_bridge_node = Node( + package="alliander_xsens", + executable="imu_bridge_node", + remappings=[ + ("/topic_in_linear_acceleration", "imu/acceleration"), + ("/topic_in_angular_velocity", "imu/angular_velocity"), + ("/topic_out_imu", "imu/data_raw"), + ], + namespace=imu_config.namespace, + ) + + madgwick_filter_node = Node( + package="imu_filter_madgwick", + executable="imu_filter_madgwick_node", + namespace=imu_config.namespace, + ) + + return [ + # Register.on_start(state_publisher, context), + # Register.on_start(static_tf, context), + Register.on_start(hardware, context) if not imu_config.simulation else SKIP, + Register.on_start(imu_bridge_node, context), + Register.on_start(madgwick_filter_node, context), + ] + + +def generate_launch_description() -> LaunchDescription: + """Generate the launch description for the Panther robot. + + Returns: + LaunchDescription: The launch description for the Panther robot. + """ + return LaunchDescription( + [ + platform_arg.declaration, + OpaqueFunction(function=launch_setup), + ] + ) diff --git a/alliander_xsens/src/alliander_xsens/package.xml b/alliander_xsens/src/alliander_xsens/package.xml new file mode 100644 index 000000000..e27c440c8 --- /dev/null +++ b/alliander_xsens/src/alliander_xsens/package.xml @@ -0,0 +1,30 @@ + + + + + + + alliander_xsens + 0.1.0 + Contains the Alliander Robotics software for the Xsens IMU. + Alliander Robotics + Apache 2.0 + + ament_cmake + ament_cmake_python + + rclcpp + rclcpp_action + geometry_msgs + sensor_msgs + + ament_lint_auto + + + ament_cmake + + diff --git a/alliander_xsens/src/alliander_xsens/src/imu_bridge.cpp b/alliander_xsens/src/alliander_xsens/src/imu_bridge.cpp new file mode 100644 index 000000000..9c01a5278 --- /dev/null +++ b/alliander_xsens/src/alliander_xsens/src/imu_bridge.cpp @@ -0,0 +1,33 @@ +// # SPDX-FileCopyrightText: Alliander N. V. +// +// # SPDX-License-Identifier: Apache-2.0 + +#include "imu_bridge.hpp" + +ImuBridge::ImuBridge() : Node("imu_bridge") { + sub_accel = this->create_subscription( + "/topic_in_linear_acceleration", 1, + [this](const geometry_msgs::msg::Vector3Stamped msg) { + this->latest_accel_msg = msg; + this->publish_imu(); + }); + + sub_ang_vel = this->create_subscription( + "/topic_in_angular_velocity", 1, + [this](const geometry_msgs::msg::Vector3Stamped msg) { + this->latest_ang_vel_msg = msg; + }); + + pub_imu = this->create_publisher("/topic_out_imu", 1); +} + +void ImuBridge::publish_imu() { + sensor_msgs::msg::Imu msg; + msg.header.stamp = this->latest_accel_msg.header.stamp; + msg.header.frame_id = this->latest_accel_msg.header.frame_id; + + msg.linear_acceleration = this->latest_accel_msg.vector; + msg.angular_velocity = this->latest_ang_vel_msg.vector; + + pub_imu->publish(msg); +} diff --git a/alliander_xsens/src/alliander_xsens/src/main.cpp b/alliander_xsens/src/alliander_xsens/src/main.cpp new file mode 100644 index 000000000..985af3ce6 --- /dev/null +++ b/alliander_xsens/src/alliander_xsens/src/main.cpp @@ -0,0 +1,12 @@ +// # SPDX-FileCopyrightText: Alliander N. V. +// +// # SPDX-License-Identifier: Apache-2.0 + +#include "imu_bridge.hpp" + +int main(int argc, char** argv) { + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} diff --git a/components.yml b/components.yml index c3a009c3b..fb6c29306 100644 --- a/components.yml +++ b/components.yml @@ -66,6 +66,10 @@ visualization: base_image: allianderrobotics/base repository: allianderrobotics/visualization dockerfile: alliander_visualization/alliander_visualization.Dockerfile +xsens: + base_image: allianderrobotics/base + repository: allianderrobotics/xsens + dockerfile: alliander_xsens/alliander_xsens.Dockerfile # CUDA components: zed: diff --git a/predefined_configurations.py b/predefined_configurations.py index d25a7fc78..4c8523a20 100644 --- a/predefined_configurations.py +++ b/predefined_configurations.py @@ -12,6 +12,7 @@ GPS, Arm, Camera, + Imu, Lidar, Platform, PlatformList, @@ -104,6 +105,10 @@ def config_velodyne(self) -> None: # noqa: D102 def config_realsense(self) -> None: # noqa: D102 self.plat_conf.platforms = [Camera("realsense", (0, 0, 0.5))] + @register_configuration("xsens") + def config_xsense(self) -> None: + self.plat_conf.platforms = [Imu("xsens", (0, 0, 0.5))] + @register_configuration("zed") def config_zed(self) -> None: # noqa: D102 self.plat_conf.platforms = [Camera("zed", (0, 0, 0.5), namespace="zed")] diff --git a/pyproject.toml b/pyproject.toml index e7c4f6d64..fdd36d61d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ version = "0.1.0" requires-python = "==3.12.*" dependencies = [ "mashumaro>=3.17", + "pyserial>=3.5", "pyyaml>=6.0.3", "termcolor>=3.3.0", "xacro>=2.1.1", @@ -53,6 +54,9 @@ alliander-visualization = [ "simplejpeg>=1.9.0", "tornado>=6.5.4", ] +alliander-xsens = [ + "pyserial>=3.5", +] ros = [ "catkin-pkg>=1.1.0", "lark>=1.3.1", diff --git a/uv.lock b/uv.lock index caac6a249..5d43270b4 100644 --- a/uv.lock +++ b/uv.lock @@ -82,6 +82,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "mashumaro" }, + { name = "pyserial" }, { name = "pyyaml" }, { name = "termcolor" }, { name = "xacro" }, @@ -110,6 +111,9 @@ alliander-visualization = [ { name = "simplejpeg" }, { name = "tornado" }, ] +alliander-xsens = [ + { name = "pyserial" }, +] documentation = [ { name = "myst-parser" }, { name = "sphinx" }, @@ -139,6 +143,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "mashumaro", specifier = ">=3.17" }, + { name = "pyserial", specifier = ">=3.5" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "termcolor", specifier = ">=3.3.0" }, { name = "xacro", specifier = ">=2.1.1" }, @@ -163,6 +168,7 @@ alliander-visualization = [ { name = "simplejpeg", specifier = ">=1.9.0" }, { name = "tornado", specifier = ">=6.5.4" }, ] +alliander-xsens = [{ name = "pyserial", specifier = ">=3.5" }] documentation = [ { name = "myst-parser", specifier = ">=5.0.0" }, { name = "sphinx", specifier = ">=9.1.0" }, @@ -983,6 +989,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/68/915cc32c02a91e76d02c8f55d5a138d6ef9e47a0d96d259df98f4842e558/pyproj-3.7.2-cp312-cp312-win_arm64.whl", hash = "sha256:509a146d1398bafe4f53273398c3bb0b4732535065fa995270e52a9d3676bca3", size = 6233452, upload-time = "2025-08-14T12:04:27.287Z" }, ] +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + [[package]] name = "pytest" version = "9.0.2" From 2c9eae60e9b6ac17e7fe9353ce44243980deb43e Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 9 Apr 2026 16:55:39 +0200 Subject: [PATCH 002/119] added mesh & urdf Signed-off-by: Peter Geurts --- .../src/alliander_description/CMakeLists.txt | 2 +- .../xsens/meshes/MTi-6x0R.stl | Bin 0 -> 199384 bytes .../xsens/urdf/xsens.urdf.xacro | 43 ++++++++++++++++++ .../src/alliander_xsens/CMakeLists.txt | 2 +- .../config/xsens_mti_node.yaml | 2 +- .../alliander_xsens/launch/xsens.launch.py | 42 +++++++++-------- .../src/alliander_xsens/src/imu_bridge.cpp | 1 + 7 files changed, 70 insertions(+), 22 deletions(-) create mode 100644 alliander_core/src/alliander_description/xsens/meshes/MTi-6x0R.stl create mode 100644 alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro diff --git a/alliander_core/src/alliander_description/CMakeLists.txt b/alliander_core/src/alliander_description/CMakeLists.txt index bc5167f99..ccb6a8186 100644 --- a/alliander_core/src/alliander_description/CMakeLists.txt +++ b/alliander_core/src/alliander_description/CMakeLists.txt @@ -11,7 +11,7 @@ find_package(ament_cmake_python REQUIRED) # Shared folders: install( - DIRECTORY franka husarion ouster velodyne realsense zed nmea_gps + DIRECTORY franka husarion nmea_gps ouster velodyne realsense xsens zed DESTINATION share/${PROJECT_NAME} ) diff --git a/alliander_core/src/alliander_description/xsens/meshes/MTi-6x0R.stl b/alliander_core/src/alliander_description/xsens/meshes/MTi-6x0R.stl new file mode 100644 index 0000000000000000000000000000000000000000..5b08e0ed4a70db5e26c2bc88977697c4298000b8 GIT binary patch literal 199384 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 literal 0 HcmV?d00001 diff --git a/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro b/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro new file mode 100644 index 000000000..dfd958e24 --- /dev/null +++ b/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/alliander_xsens/src/alliander_xsens/CMakeLists.txt b/alliander_xsens/src/alliander_xsens/CMakeLists.txt index 99b70fd1e..5573c3921 100644 --- a/alliander_xsens/src/alliander_xsens/CMakeLists.txt +++ b/alliander_xsens/src/alliander_xsens/CMakeLists.txt @@ -30,7 +30,7 @@ install( # Shared folders: install( - DIRECTORY launch + DIRECTORY config launch DESTINATION share/${PROJECT_NAME} ) diff --git a/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml index 5064189d6..d3665d9ec 100644 --- a/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml +++ b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml @@ -62,7 +62,7 @@ # --------------------------- # If you want to configure your sensor, firstly set this flag to true, then change desired values for the parameters below. # Note: This configuration is required only once, once it is successful, the configurations are stored in the sensor, then you could set this to false. - enable_deviceConfig: false + enable_deviceConfig: false # Sensor Extended Kalman Filter Option Configurations: # -------------------------- diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index 194ae564f..ccf53ffd7 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -35,34 +35,37 @@ def launch_setup(context: LaunchContext) -> list: print(f"Found multiple IMU devices with VID:PID {VID}:{PID}!") imu_device = device.name - if imu_device is None: + if imu_device is None and not imu_config.simulation: print("No IMU device found, exiting.") exit(1) imu_config.usb_device = imu_device - # state_publisher = state_publisher_node( - # namespace=imu_config.namespace, - # platform="xsens", - # xacro="xsens.urdf.xacro", - # xacro_arguments={ - # "parent": "" if imu_config.parent.link else "world", - # }, - # ) - # - # parent = imu_config.parent - # static_tf = static_tf_node( - # parent_frame=f"{parent.namespace}/{parent.link}" if parent.link else "map", - # child_frame=f"{imu_config.namespace}/{parent.connects_to}", - # position=imu_config.position, - # orientation=imu_config.orientation, - # ) + state_publisher = state_publisher_node( + namespace=imu_config.namespace, + platform="xsens", + xacro="xsens.urdf.xacro", + xacro_arguments={ + "parent": "" if imu_config.parent.link else "world", + }, + ) + + parent = imu_config.parent + static_tf = static_tf_node( + parent_frame=f"{parent.namespace}/{parent.link}" if parent.link else "map", + child_frame=f"{imu_config.namespace}/{parent.connects_to}", + position=imu_config.position, + orientation=imu_config.orientation, + ) + parameter_file = get_file_path("alliander_xsens", ["config"], "xsens_mti_node.yaml") hardware = Node( package="xsens_mti_ros2_driver", executable="xsens_mti_node", + parameters=[parameter_file], remappings=[ ("/imu/acceleration", "imu/acceleration"), ("/imu/angular_velocity", "imu/angular_velocity"), + ("/imu/mag", "imu/mag"), ], namespace=imu_config.namespace, ) @@ -81,12 +84,13 @@ def launch_setup(context: LaunchContext) -> list: madgwick_filter_node = Node( package="imu_filter_madgwick", executable="imu_filter_madgwick_node", + remappings=[], namespace=imu_config.namespace, ) return [ - # Register.on_start(state_publisher, context), - # Register.on_start(static_tf, context), + Register.on_start(state_publisher, context), + Register.on_start(static_tf, context), Register.on_start(hardware, context) if not imu_config.simulation else SKIP, Register.on_start(imu_bridge_node, context), Register.on_start(madgwick_filter_node, context), diff --git a/alliander_xsens/src/alliander_xsens/src/imu_bridge.cpp b/alliander_xsens/src/alliander_xsens/src/imu_bridge.cpp index 9c01a5278..fec081a91 100644 --- a/alliander_xsens/src/alliander_xsens/src/imu_bridge.cpp +++ b/alliander_xsens/src/alliander_xsens/src/imu_bridge.cpp @@ -19,6 +19,7 @@ ImuBridge::ImuBridge() : Node("imu_bridge") { }); pub_imu = this->create_publisher("/topic_out_imu", 1); + RCLCPP_INFO(this->get_logger(), "Started IMU bridge topic."); } void ImuBridge::publish_imu() { From ec6a2077d12165166147cec267127cbfb2c04b82 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Fri, 10 Apr 2026 10:23:21 +0200 Subject: [PATCH 003/119] fixed linting errors Signed-off-by: Peter Geurts --- .gitignore | 3 ++- .../xsens/urdf/xsens.urdf.xacro | 4 ++++ .../src/alliander_xsens/config/xsens_mti_node.yaml | 3 +++ .../src/alliander_xsens/include/imu_bridge.hpp | 2 -- .../src/alliander_xsens/launch/xsens.launch.py | 14 ++++++++------ predefined_configurations.py | 2 +- 6 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 992504cfc..f4c655787 100644 --- a/.gitignore +++ b/.gitignore @@ -22,9 +22,10 @@ clangd/ .venv # for LSP in neovim -.clangd-build/ +.nvim/ **/compile_commands.json # generated compose files compose.yml compose_pytest.yml +rviz.yml diff --git a/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro b/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro index dfd958e24..0a3ae3a0e 100644 --- a/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro +++ b/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro @@ -1,4 +1,8 @@ + diff --git a/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml index d3665d9ec..9fe2c053e 100644 --- a/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml +++ b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 /**: ros__parameters: # Device Scanning Configuration diff --git a/alliander_xsens/src/alliander_xsens/include/imu_bridge.hpp b/alliander_xsens/src/alliander_xsens/include/imu_bridge.hpp index e61b1bf6b..59da4e0ab 100644 --- a/alliander_xsens/src/alliander_xsens/include/imu_bridge.hpp +++ b/alliander_xsens/src/alliander_xsens/include/imu_bridge.hpp @@ -16,10 +16,8 @@ class ImuBridge : public rclcpp::Node { public: /** * @brief constructor for the ImuBridge class. - * @param node The ROS2 node to attach to. */ ImuBridge(); - ~ImuBridge() = default; private: // ROS2 communication variables: diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index ccf53ffd7..0a23a8f16 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -2,10 +2,12 @@ # # SPDX-License-Identifier: Apache-2.0 +import sys + from alliander_utilities.config_objects import Imu from alliander_utilities.launch_argument import LaunchArgument from alliander_utilities.launch_utils import SKIP, state_publisher_node, static_tf_node -from alliander_utilities.register import Register, RegisteredLaunchDescription +from alliander_utilities.register import Register from alliander_utilities.ros_utils import get_file_path from launch import LaunchContext, LaunchDescription from launch.actions import OpaqueFunction @@ -26,18 +28,18 @@ def launch_setup(context: LaunchContext) -> list: """ imu_config = Imu.from_str(platform_arg.string_value(context)) - VID = "2639" - PID = "0301" + vid = "2639" + pid = "0301" imu_device = None - for device in list_ports.grep(f"{VID}:{PID}"): + for device in list_ports.grep(f"{vid}:{pid}"): print(f"Found IMU device {device}") if imu_device is not None: - print(f"Found multiple IMU devices with VID:PID {VID}:{PID}!") + print(f"Found multiple IMU devices with vid:pid {vid}:{pid}!") imu_device = device.name if imu_device is None and not imu_config.simulation: print("No IMU device found, exiting.") - exit(1) + sys.exit(1) imu_config.usb_device = imu_device state_publisher = state_publisher_node( diff --git a/predefined_configurations.py b/predefined_configurations.py index 4c8523a20..0c26778c8 100644 --- a/predefined_configurations.py +++ b/predefined_configurations.py @@ -106,7 +106,7 @@ def config_realsense(self) -> None: # noqa: D102 self.plat_conf.platforms = [Camera("realsense", (0, 0, 0.5))] @register_configuration("xsens") - def config_xsense(self) -> None: + def config_xsense(self) -> None: # noqa: D102 self.plat_conf.platforms = [Imu("xsens", (0, 0, 0.5))] @register_configuration("zed") From 4080cabd6e73d48312062ac950ab51161061688a Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Fri, 10 Apr 2026 13:17:24 +0200 Subject: [PATCH 004/119] added license file Signed-off-by: Peter Geurts --- .../xsens/meshes/MTi-6x0R.stl.license | 3 +++ .../alliander_xsens/launch/xsens.launch.py | 21 ++++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 alliander_core/src/alliander_description/xsens/meshes/MTi-6x0R.stl.license diff --git a/alliander_core/src/alliander_description/xsens/meshes/MTi-6x0R.stl.license b/alliander_core/src/alliander_description/xsens/meshes/MTi-6x0R.stl.license new file mode 100644 index 000000000..14a6ee96d --- /dev/null +++ b/alliander_core/src/alliander_description/xsens/meshes/MTi-6x0R.stl.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Alliander N. V. + +SPDX-License-Identifier: Apache-2.0 diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index 0a23a8f16..3038a473c 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 import sys +import time from alliander_utilities.config_objects import Imu from alliander_utilities.launch_argument import LaunchArgument @@ -31,16 +32,16 @@ def launch_setup(context: LaunchContext) -> list: vid = "2639" pid = "0301" imu_device = None - for device in list_ports.grep(f"{vid}:{pid}"): - print(f"Found IMU device {device}") - if imu_device is not None: - print(f"Found multiple IMU devices with vid:pid {vid}:{pid}!") - imu_device = device.name - - if imu_device is None and not imu_config.simulation: - print("No IMU device found, exiting.") - sys.exit(1) - imu_config.usb_device = imu_device + + while imu_device is None and not imu_config.simulation: + for device in list_ports.grep(f"{vid}:{pid}"): + print(f"Found IMU device {device}") + imu_device = device.name + if imu_device is None: + print( + f"No Xsens IMU device (VID:PID {vid}:{pid}) found yet, make sure one is connected." + ) + time.sleep(1.0) state_publisher = state_publisher_node( namespace=imu_config.namespace, From d135898a20116036742b0744095d615c142ab0c0 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Tue, 24 Feb 2026 11:58:11 +0100 Subject: [PATCH 005/119] switched to SmacPlanner2D; first tuning of mppi; added vehicle trajectory to Rviz Signed-off-by: Peter Geurts --- .../config/nav2/controllers/mppi.yaml | 29 +++++++------------ .../config/nav2/planner_server.yaml | 3 +- .../alliander_visualization/rviz.py | 24 ++++++++++++++- .../alliander_visualization/tool_manager.py | 3 +- predefined_configurations.py | 1 + 5 files changed, 38 insertions(+), 22 deletions(-) diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/controllers/mppi.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/controllers/mppi.yaml index c5e5f2c86..b97a0282b 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/controllers/mppi.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/controllers/mppi.yaml @@ -8,28 +8,23 @@ controller_server: ros__parameters: FollowPath: plugin: "nav2_mppi_controller::MPPIController" + motion_model: "DiffDrive" + visualize: true # computationally expensive, set to false on robot time_steps: 56 - model_dt: 0.05 - batch_size: 2000 + model_dt: 0.05 # set this to 1 / controller_frequency + batch_size: 3000 vx_std: 0.2 - vy_std: 0.2 wz_std: 0.4 - vx_max: 0.5 - vx_min: -0.35 - vy_max: 0.5 - wz_max: 1.9 - ax_max: 3.0 - ax_min: -3.0 - ay_min: -3.0 - ay_max: 3.0 - az_max: 3.5 + vx_max: 2.0 + vx_min: -1.0 + wz_max: 3.14 + ax_max: 10.0 + ax_min: -10.0 + az_max: 10.0 iteration_count: 1 - prune_distance: 1.7 transform_tolerance: 0.1 temperature: 0.3 gamma: 0.015 - motion_model: "DiffDrive" - visualize: false reset_period: 1.0 # (only in Humble) regenerate_noises: false TrajectoryVisualizer: @@ -39,8 +34,6 @@ controller_server: plugin: "mppi::DefaultOptimalTrajectoryValidator" collision_lookahead_time: 2.0 consider_footprint: false - AckermannConstraints: - min_turning_r: 0.2 critics: [ "ConstraintCritic", @@ -94,7 +87,7 @@ controller_server: PathAlignCritic: enabled: true cost_power: 1 - cost_weight: 14.0 + cost_weight: 5.0 max_path_occupancy_ratio: 0.05 trajectory_point_step: 4 threshold_to_consider: 0.5 diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/planner_server.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/planner_server.yaml index 684aa6526..1735b02a2 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/planner_server.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/planner_server.yaml @@ -8,6 +8,5 @@ planner_server: ros__parameters: planner_plugins: ["GridBased"] GridBased: - plugin: "nav2_smac_planner::SmacPlannerHybrid" - minimum_turning_radius: 0.0 + plugin: "nav2_smac_planner::SmacPlanner2D" diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py index ae544add4..3360e6b71 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py @@ -144,6 +144,28 @@ def add_laser_scan(namespace: str) -> None: } ) + @staticmethod + def add_vehicle_trajectory(topic: str) -> None: + """Add a controller trajectory to the RViz configuration. + + Args: + topic (str): The topic of the trajectory. + """ + Rviz.displays.append( + { + "Enabled": True, + "Class": "rviz_default_plugins/Path", + "Name": topic, + "Topic": {"Value": topic}, + "Pose Style": "Arrows", + "Pose Color": "255; 85; 255", + "Shaft Length": 0.3, + "Head Length": 0.2, + "Shaft Diameter": 0.05, + "Head Diameter": 0.1, + } + ) + @staticmethod def add_motion_planning_plugin(namespace: str) -> None: """Add the motion planning plugin to the RViz configuration. @@ -201,7 +223,7 @@ def add_robot_state(namespace: str) -> None: ) @staticmethod - def add_trajectory(namespace: str) -> None: + def add_arm_trajectory(namespace: str) -> None: """Add the trajectory display to the RViz configuration. Args: diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py index 49100f4ea..49a630db8 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py @@ -108,7 +108,7 @@ def add_arm(platform: Arm) -> None: ApplyConfigurations.add_description(ns, kinematic=True) Rviz.add_planning_scene(ns) Rviz.add_robot_state(ns) - Rviz.add_trajectory(ns) + Rviz.add_arm_trajectory(ns) Rviz.add_markers() if platform.moveit_config.load_rviz_motion_planning_plugin: Rviz.add_motion_planning_plugin(ns) @@ -130,6 +130,7 @@ def add_vehicle(platform: Vehicle) -> None: if nav2.navigation: Rviz.add_map(f"/{ns}/global_costmap/costmap") Rviz.add_path(f"/{ns}/plan") + Rviz.add_vehicle_trajectory(f"/{ns}/optimal_trajectory") Vizanti.add_button("Stop", f"/{ns}/waypoint_follower_controller/stop") Vizanti.add_initial_pose() Vizanti.add_goal_pose() diff --git a/predefined_configurations.py b/predefined_configurations.py index 0c26778c8..f7aabe34d 100644 --- a/predefined_configurations.py +++ b/predefined_configurations.py @@ -216,6 +216,7 @@ def config_panther_lidar_navigation(self) -> None: # noqa: D102 @register_configuration("panther_gps_navigation") def config_panther_gps_navigation(self) -> None: # noqa: D102 vehicle = Vehicle("panther", (0, 0, 0.2)) + vehicle.nav2_config.controller = "mppi" vehicle.nav2_config.navigation = True vehicle.nav2_config.gps = True vehicle.nav2_config.window_size = 50 From f61864b229a03e58921864336bb2c91992792ab5 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 26 Feb 2026 16:11:16 +0100 Subject: [PATCH 006/119] removed superfluous target_frame="" in lidar launches; changed map to location with more obstacles; increased inflation radius and decreased cost scaling factor to make falloff costs smoother Signed-off-by: Peter Geurts --- .../src/alliander_nav2/config/nav2/global_costmap.yaml | 4 ++-- alliander_ouster/src/alliander_ouster/launch/ouster.launch.py | 2 -- .../src/alliander_velodyne/launch/velodyne.launch.py | 2 -- .../alliander_visualization/alliander_visualization/rviz.py | 1 + predefined_configurations.py | 2 +- 5 files changed, 4 insertions(+), 7 deletions(-) diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/global_costmap.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/global_costmap.yaml index 01a980036..c7ef958b1 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/global_costmap.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/global_costmap.yaml @@ -7,7 +7,6 @@ global_costmap: global_costmap: ros__parameters: - always_send_full_costmap: true rolling_window: substitute_me width: substitute_me height: substitute_me @@ -39,4 +38,5 @@ global_costmap: inf_is_valid: false inflation_layer: plugin: "nav2_costmap_2d::InflationLayer" - inflation_radius: 1.0 + inflation_radius: 1.5 + cost_scaling_factor: 2.0 diff --git a/alliander_ouster/src/alliander_ouster/launch/ouster.launch.py b/alliander_ouster/src/alliander_ouster/launch/ouster.launch.py index 8d02c1339..94942837c 100644 --- a/alliander_ouster/src/alliander_ouster/launch/ouster.launch.py +++ b/alliander_ouster/src/alliander_ouster/launch/ouster.launch.py @@ -47,7 +47,6 @@ def launch_setup(context: LaunchContext) -> list: {"platform_config": lidar_config.to_str()}, ) - target_frame = "" pointcloud_to_laserscan_node = Node( package="pointcloud_to_laserscan", executable="pointcloud_to_laserscan_node", @@ -57,7 +56,6 @@ def launch_setup(context: LaunchContext) -> list: ], parameters=[ { - "target_frame": target_frame, "min_height": 0.1, "max_height": 2.0, "range_min": 0.05, diff --git a/alliander_velodyne/src/alliander_velodyne/launch/velodyne.launch.py b/alliander_velodyne/src/alliander_velodyne/launch/velodyne.launch.py index c262cd381..68c8b54c6 100644 --- a/alliander_velodyne/src/alliander_velodyne/launch/velodyne.launch.py +++ b/alliander_velodyne/src/alliander_velodyne/launch/velodyne.launch.py @@ -47,7 +47,6 @@ def launch_setup(context: LaunchContext) -> list: {"platform_config": lidar_config.to_str()}, ) - target_frame = "" pointcloud_to_laserscan_node = Node( package="pointcloud_to_laserscan", executable="pointcloud_to_laserscan_node", @@ -57,7 +56,6 @@ def launch_setup(context: LaunchContext) -> list: ], parameters=[ { - "target_frame": target_frame, "min_height": 0.1, "max_height": 2.0, "range_min": 0.05, diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py index 3360e6b71..5a38a27d5 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py @@ -325,6 +325,7 @@ def add_satellite(topic: str) -> None: }, "Value": True, "Zoom": 19, + "Draw Behind": True, } ) diff --git a/predefined_configurations.py b/predefined_configurations.py index f7aabe34d..1304f9646 100644 --- a/predefined_configurations.py +++ b/predefined_configurations.py @@ -227,7 +227,7 @@ def config_panther_gps_navigation(self) -> None: # noqa: D102 link(vehicle, gps) self.plat_conf.platforms = [vehicle, lidar, gps] self.viz_conf.gui = True - self.sim_conf.world = "map_5.940906_51.966960" + self.sim_conf.world = "map_5.954036_51.977320" # Lynx: @register_configuration("lynx") From e386b578467ff7dc37ff67d5d7c1c145199461e0 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Fri, 27 Feb 2026 16:46:15 +0100 Subject: [PATCH 007/119] added local costmap; tuned parameters Signed-off-by: Peter Geurts --- .../src/alliander_gazebo/worlds/world.sdf | 4 +-- .../config/nav2/controller_server.yaml | 7 ++--- .../config/nav2/controllers/mppi.yaml | 10 +++---- .../config/nav2/global_costmap.yaml | 5 ++-- .../config/nav2/local_costmap.yaml | 28 +++++++++++++++++-- .../src/alliander_nav2/launch/nav2.launch.py | 12 ++++++++ .../alliander_visualization/rviz.py | 12 ++++++-- .../alliander_visualization/tool_manager.py | 4 ++- 8 files changed, 61 insertions(+), 21 deletions(-) diff --git a/alliander_gazebo/src/alliander_gazebo/worlds/world.sdf b/alliander_gazebo/src/alliander_gazebo/worlds/world.sdf index d13d12128..165e50657 100644 --- a/alliander_gazebo/src/alliander_gazebo/worlds/world.sdf +++ b/alliander_gazebo/src/alliander_gazebo/worlds/world.sdf @@ -7,7 +7,7 @@ SPDX-License-Identifier: Apache-2.0 - 0.001 + 0.0025 1.0 @@ -65,4 +65,4 @@ SPDX-License-Identifier: Apache-2.0 - \ No newline at end of file + diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/controller_server.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/controller_server.yaml index 5fc7ed8c8..1f5a54b9a 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/controller_server.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/controller_server.yaml @@ -7,7 +7,6 @@ controller_server: ros__parameters: enable_stamped_cmd_vel: true - progress_checker_plugins: ["progress_checker"] # progress_checker_plugin: "progress_checker" For Humble and older - progress_checker: - plugin: "nav2_controller::SimpleProgressChecker" - movement_time_allowance: 40.0 + PathHandler: + prune_distance: 5.5 # at least as large as furthest distance of interest by critic + diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/controllers/mppi.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/controllers/mppi.yaml index b97a0282b..1ec0e66ed 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/controllers/mppi.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/controllers/mppi.yaml @@ -12,9 +12,9 @@ controller_server: visualize: true # computationally expensive, set to false on robot time_steps: 56 model_dt: 0.05 # set this to 1 / controller_frequency - batch_size: 3000 - vx_std: 0.2 - wz_std: 0.4 + batch_size: 2000 + vx_std: 0.35 + wz_std: 0.6 vx_max: 2.0 vx_min: -1.0 wz_max: 3.14 @@ -67,14 +67,12 @@ controller_server: ObstaclesCritic: enabled: true cost_power: 1 - repulsion_weight: 1.5 + repulsion_weight: 1.5 # set to inflation_radius - min_dist_to_obstacle critical_weight: 20.0 consider_footprint: false collision_cost: 10000.0 collision_margin_distance: 0.1 near_goal_distance: 0.5 - inflation_radius: 0.55 # (only in Humble) - cost_scaling_factor: 10.0 # (only in Humble) CostCritic: enabled: true cost_power: 1 diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/global_costmap.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/global_costmap.yaml index c7ef958b1..451ab5acb 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/global_costmap.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/global_costmap.yaml @@ -10,8 +10,9 @@ global_costmap: rolling_window: substitute_me width: substitute_me height: substitute_me - update_frequency: 30.0 - publish_frequency: 30.0 + update_frequency: 20.0 + publish_frequency: 20.0 + always_send_full_costmap: true robot_base_frame: substitute_me footprint: "[[0.4045, 0.424], [0.4045, -0.424], [-0.4045, -0.424], [-0.4045, 0.424]]" plugins: ["static_layer", "obstacle_layer", "inflation_layer"] diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/local_costmap.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/local_costmap.yaml index 39d632277..66507fce8 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/local_costmap.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/local_costmap.yaml @@ -10,10 +10,32 @@ local_costmap: global_frame: substitute_me robot_base_frame: substitute_me rolling_window: substitute_me - plugins: ["static_layer", "obstacle_layer", "inflation_layer"] - static_layer: - plugin: "nav2_costmap_2d::StaticLayer" + update_frequency: 10.0 + publish_frequency: 5.0 + width: substitute_me + height: substitute_me + footprint: "[[0.4045, 0.424], [0.4045, -0.424], [-0.4045, -0.424], [-0.4045, 0.424]]" + plugins: ["obstacle_layer", "inflation_layer"] obstacle_layer: plugin: "nav2_costmap_2d::ObstacleLayer" + enabled: True + observation_sources: scan + footprint_clearing_enabled: true + max_obstacle_height: 2.0 + combination_method: 1 + scan: + topic: substitute_me + obstacle_max_range: substitute_me + obstacle_min_range: 0.0 + raytrace_max_range: substitute_me + raytrace_min_range: 0.0 + max_obstacle_height: 2.0 + min_obstacle_height: 0.0 + clearing: True + marking: True + data_type: "LaserScan" + inf_is_valid: false inflation_layer: plugin: "nav2_costmap_2d::InflationLayer" + inflation_radius: substitute_me + cost_scaling_factor: 1.0 diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 1696cba47..ba32cf48b 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -101,7 +101,19 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 "global_frame": f"{namespace_vehicle}/odom", "robot_base_frame": f"{namespace_vehicle}/base_footprint", "rolling_window": nav2.gps, + "width": 10, + "height": 10, "plugins": plugins, + "obstacle_layer": { + "scan": { + "topic": f"/{namespace_lidar}/scan", + "obstacle_max_range": 10.0, + "raytrace_max_range": 10.0, + } + }, + "inflation_layer": { + "inflation_radius": float(5), # width / 2 + }, }, root_key=namespace_vehicle, ) diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py index 5a38a27d5..4c6baf7e8 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py @@ -119,6 +119,9 @@ def add_point_cloud(namespace: str) -> None: "Class": "rviz_default_plugins/PointCloud2", "Topic": {"Value": f"/{namespace}/scan/points"}, "Name": namespace, + "Use rainbow": False, + "Min Color": "170; 0; 255", + "Alpha": 0.3, } ) @@ -140,7 +143,8 @@ def add_laser_scan(namespace: str) -> None: "Name": namespace, "Color Transformer": "FlatColor", "Color": "255; 0; 0", - "Size (m)": 0.02, + "Style": "Spheres", + "Size (m)": 0.03, } ) @@ -241,13 +245,14 @@ def add_arm_trajectory(namespace: str) -> None: ) @staticmethod - def add_map(topic: str) -> None: + def add_map(topic: str, color_scheme: str = "costmap", alpha: float = 0.7) -> None: """Add a map to the RViz configuration. Args: topic (str): The topic of the map. + color_scheme (str): The color scheme of the map. + alpha (float): The transparency level of the map. """ - color_scheme = "costmap" if "costmap" in topic else "map" Rviz.displays.append( { "Enabled": True, @@ -255,6 +260,7 @@ def add_map(topic: str) -> None: "Name": topic, "Topic": {"Value": topic}, "Color Scheme": color_scheme, + "Alpha": alpha, } ) diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py index 49a630db8..04ab43ba8 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py @@ -125,10 +125,11 @@ def add_vehicle(platform: Vehicle) -> None: Vizanti.add_platform_model(ns) if (nav2.navigation or nav2.slam) and not nav2.gps: - Rviz.add_map(f"/{ns}/map") + Rviz.add_map(f"/{ns}/map", "map") if nav2.navigation: Rviz.add_map(f"/{ns}/global_costmap/costmap") + Rviz.add_map(f"/{ns}/local_costmap/costmap", "map", 0.3) Rviz.add_path(f"/{ns}/plan") Rviz.add_vehicle_trajectory(f"/{ns}/optimal_trajectory") Vizanti.add_button("Stop", f"/{ns}/waypoint_follower_controller/stop") @@ -154,6 +155,7 @@ def add_lidar(platform: Lidar) -> None: platform (Lidar): The lidar platform configuration. """ Rviz.add_laser_scan(platform.namespace) + Rviz.add_point_cloud(platform.namespace) @staticmethod def add_depth_camera(platform: Camera) -> None: From 2ac049da2cb8f0b5a0dec52d50b520187fd8ecd6 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Mon, 2 Mar 2026 10:56:57 +0100 Subject: [PATCH 008/119] updated velodyne position on panther Signed-off-by: Peter Geurts --- predefined_configurations.py | 42 ++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/predefined_configurations.py b/predefined_configurations.py index 1304f9646..02696cb4b 100644 --- a/predefined_configurations.py +++ b/predefined_configurations.py @@ -163,7 +163,12 @@ def config_panther_zed(self) -> None: # noqa: D102 @register_configuration("panther_velodyne") def config_panther_velodyne(self) -> None: # noqa: D102 vehicle = Vehicle("panther", (0, 0, 0.2)) - lidar = Lidar("velodyne", (0.13, -0.13, 0.35), ip_address="10.15.20.5") + lidar = Lidar( + "velodyne", + position=(0.125, 0.185, 0.20), + orientation=(0.0, 0.0, 45.0), + ip_address="10.15.20.5", + ) link(vehicle, lidar) self.plat_conf.platforms = [vehicle, lidar] @@ -189,7 +194,12 @@ def config_panther_gps(self) -> None: # noqa: D102 def config_panther_collision_monitor(self) -> None: # noqa: D102 vehicle = Vehicle("panther", (0, 0, 0.2)) vehicle.nav2_config.collision_monitor = True - lidar = Lidar("velodyne", (0.13, -0.13, 0.35)) + lidar = Lidar( + "velodyne", + position=(0.125, 0.185, 0.20), + orientation=(0.0, 0.0, 45.0), + ip_address="10.15.20.5", + ) link(vehicle, lidar) self.plat_conf.platforms = [vehicle, lidar] @@ -198,7 +208,12 @@ def config_panther_collision_monitor(self) -> None: # noqa: D102 def config_panther_slam(self) -> None: # noqa: D102 vehicle = Vehicle("panther", (0, 0, 0.2)) vehicle.nav2_config.slam = True - lidar = Lidar("velodyne", (0.13, -0.13, 0.35)) + lidar = Lidar( + "velodyne", + position=(0.125, 0.185, 0.20), + orientation=(0.0, 0.0, 45.0), + ip_address="10.15.20.5", + ) link(vehicle, lidar) self.plat_conf.platforms = [vehicle, lidar] @@ -207,7 +222,12 @@ def config_panther_slam(self) -> None: # noqa: D102 def config_panther_lidar_navigation(self) -> None: # noqa: D102 vehicle = Vehicle("panther", (0, 0, 0.2)) vehicle.nav2_config.navigation = True - lidar = Lidar("velodyne", (0.13, -0.13, 0.35)) + lidar = Lidar( + "velodyne", + position=(0.125, 0.185, 0.20), + orientation=(0.0, 0.0, 45.0), + ip_address="10.15.20.5", + ) link(vehicle, lidar) self.plat_conf.platforms = [vehicle, lidar] @@ -220,7 +240,12 @@ def config_panther_gps_navigation(self) -> None: # noqa: D102 vehicle.nav2_config.navigation = True vehicle.nav2_config.gps = True vehicle.nav2_config.window_size = 50 - lidar = Lidar("velodyne", (0.13, -0.13, 0.35)) + lidar = Lidar( + "velodyne", + position=(0.125, 0.185, 0.20), + orientation=(0.0, 0.0, 45.0), + ip_address="10.15.20.5", + ) gps = GPS("gps", (0, 0, 0.2)) link(vehicle, lidar) @@ -271,7 +296,12 @@ def config_mm_velodyne(self) -> None: # noqa: D102 vehicle = Vehicle("panther", (0, 0, 0.2)) vehicle.nav2_config.navigation = True arm = Arm("franka", (0, 0, 0.14), gripper=True, moveit=True) - lidar = Lidar("velodyne", (0.13, -0.13, 0.35)) + lidar = Lidar( + "velodyne", + position=(0.125, 0.185, 0.20), + orientation=(0.0, 0.0, 45.0), + ip_address="10.15.20.5", + ) link(vehicle, arm) link(vehicle, lidar) From 563b0d81e80a38fb3fcfdd4af159e8d8143d4113 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Mon, 2 Mar 2026 13:34:47 +0100 Subject: [PATCH 009/119] added realsense to panther_gps_navigation; updated camera/gps positions Signed-off-by: Peter Geurts --- predefined_configurations.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/predefined_configurations.py b/predefined_configurations.py index 02696cb4b..644601da3 100644 --- a/predefined_configurations.py +++ b/predefined_configurations.py @@ -147,7 +147,7 @@ def config_panther(self) -> None: # noqa: D102 @register_configuration("panther_realsense") def config_panther_realsense(self) -> None: # noqa: D102 vehicle = Vehicle("panther", (0, 0, 0.2)) - camera = Camera("realsense", (0, 0, 0.2)) + camera = Camera("realsense", (0.18, 0, 0.2)) link(vehicle, camera) self.plat_conf.platforms = [vehicle, camera] @@ -184,7 +184,7 @@ def config_panther_ouster(self) -> None: # noqa: D102 @register_configuration("panther_gps") def config_panther_gps(self) -> None: # noqa: D102 vehicle = Vehicle("panther", (0, 0, 0.2)) - gps = GPS("gps", (0, 0, 0.2)) + gps = GPS("gps", position=(-0.08, -0.25, 0.2), orientation=(0, 0, -90)) link(vehicle, gps) self.plat_conf.platforms = [vehicle, gps] @@ -242,15 +242,17 @@ def config_panther_gps_navigation(self) -> None: # noqa: D102 vehicle.nav2_config.window_size = 50 lidar = Lidar( "velodyne", - position=(0.125, 0.185, 0.20), - orientation=(0.0, 0.0, 45.0), + position=(0.125, 0.185, 0.2), + orientation=(0, 0, 45), ip_address="10.15.20.5", ) - gps = GPS("gps", (0, 0, 0.2)) + gps = GPS("gps", position=(-0.08, -0.25, 0.2), orientation=(0, 0, -90)) + camera = Camera("realsense", (0.18, 0, 0.2)) link(vehicle, lidar) link(vehicle, gps) - self.plat_conf.platforms = [vehicle, lidar, gps] + link(vehicle, camera) + self.plat_conf.platforms = [vehicle, lidar, gps, camera] self.viz_conf.gui = True self.sim_conf.world = "map_5.954036_51.977320" From e7d69edef1b6ccf6f2b4a82b59fece1c6c6a80e1 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Mon, 2 Mar 2026 15:40:17 +0100 Subject: [PATCH 010/119] changed gps hardware frame Signed-off-by: Peter Geurts --- .../alliander_gps/launch/hardware.launch.py | 4 +- .../test/test_gps_waypoint_follower.py | 151 ++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100755 alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py diff --git a/alliander_gps/src/alliander_gps/launch/hardware.launch.py b/alliander_gps/src/alliander_gps/launch/hardware.launch.py index e8d8892e7..04a5ceb9f 100644 --- a/alliander_gps/src/alliander_gps/launch/hardware.launch.py +++ b/alliander_gps/src/alliander_gps/launch/hardware.launch.py @@ -32,8 +32,8 @@ def launch_setup(context: LaunchContext) -> list: { "ip": gps_config.ip_address, "port": 5000, - "frame_id": "gps", - "tf_prefix": gps_config.parent.namespace, + "frame_id": "base_link", + "tf_prefix": gps_config.namespace, }, ], remappings=[ diff --git a/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py new file mode 100755 index 000000000..b5093d0da --- /dev/null +++ b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +import math +from dataclasses import dataclass +from enum import Enum, auto + +import rclpy +from geographic_msgs.msg import GeoPose +from geometry_msgs.msg import Quaternion +from nav2_msgs.action import FollowGPSWaypoints +from rclpy.action import ActionClient +from rclpy.node import Node +from rclpy.task import Future + + +@dataclass(frozen=True) +class GPSWaypoint: + """Dataclass containing lat/lon and optional yaw orientation. + + Attributes: + latitude (float): latitude of GPS waypoint. + longitude (float): longitude of GPS waypoint. + yaw (float): yaw of GPS waypoint. + """ + + latitude: float + longitude: float + yaw: float = 0.0 + + +class Route(Enum): + """Enum containing predefined routes for GPS Waypoint Follower. + + Attributes: + KB_TRUCK_PARKING_LOT: Route nearby Roboticalab entrance. + SIM_TIGHT_ALLEYS: Simulation route in Arnhem, with buildings close together. + """ + + KB_TRUCK_PARKING_LOT = auto() + SIM_TIGHT_ALLEYS = auto() + + +ROUTES: dict[Route, list[GPSWaypoint]] = { + Route.KB_TRUCK_PARKING_LOT: [ + GPSWaypoint(51.966663, 5.940867), + GPSWaypoint(51.966511, 5.940912), + GPSWaypoint(51.966512, 5.940945), + GPSWaypoint(51.966661, 5.940892, yaw=math.pi / 4), + ], + Route.SIM_TIGHT_ALLEYS: [ + GPSWaypoint(51.977291, 5.954022, yaw=-math.pi), + GPSWaypoint(51.977251, 5.954025, yaw=-math.pi / 4), + GPSWaypoint(51.977213, 5.954037, yaw=-3 * math.pi / 4), + GPSWaypoint(51.977203, 5.954130, yaw=math.pi / 6), + GPSWaypoint(51.977226, 5.954096, yaw=-5 * math.pi / 6), + GPSWaypoint(51.977172, 5.954103, yaw=-3 * math.pi / 4), + GPSWaypoint(51.977172, 5.954198, yaw=3 * math.pi / 4), + GPSWaypoint(51.977209, 5.954075, yaw=7 * math.pi / 4), + GPSWaypoint(51.977229, 5.953975, yaw=math.pi / 2), + ], +} + + +class GPSWaypointFollower(Node): + """Class that sends predefined routes to nav2's GPS waypoint follower action server.""" + + def __init__(self): + """Sets up the action client.""" + super().__init__("test_gps_waypoints") + self.ac = ActionClient( + self, FollowGPSWaypoints, "/panther/follow_gps_waypoints" + ) + + def send_goal(self, route: Route) -> Future: + """Sends a Route to the nav2 action server. + + Args: + route (Route): predefined set of waypoints to send. + + Returns: + Future: future that can be awaited by rclpy. + """ + self.ac.wait_for_server() + + waypoints = ROUTES[route] + goal_msg = FollowGPSWaypoints.Goal() + goal_msg.gps_poses = [self._to_gps_pose(wp) for wp in waypoints] + print(f"goal: {goal_msg}") + + future = self.ac.send_goal_async(goal_msg, feedback_callback=self.cb_feedback) + future.add_done_callback(self.cb_result) + return future + + def cb_feedback(self, feedback_msg: FollowGPSWaypoints.Feedback) -> None: + """Callback for feedback from the action server. + + Args: + feedback_msg (FollowGPSWaypoints.Feedback): feedback indicating current waypoint being followed. + """ + self.get_logger().info( + f"Now navigating to waypoint {feedback_msg.current_waypoint}." + ) + + def cb_result(self, result_msg: FollowGPSWaypoints.Result) -> None: + """Callback for result from the action server. + + Args: + result_msg (FollowGPSWaypoints.Result): message indicating statistics of operation. + """ + self.get_logger().info( + f"Completed waypoints.\nMissed waypoints: {result_msg.missed_waypoints}\nError msg: {result_msg.error_msg}" + ) + + @staticmethod + def _to_gps_pose(wp: GPSWaypoint) -> GeoPose: + pose = GeoPose() + pose.position.latitude = wp.latitude + pose.position.longitude = wp.longitude + pose.orientation = GPSWaypointFollower._yaw_to_quaternion(wp.yaw) + return pose + + @staticmethod + def _yaw_to_quaternion(yaw: float) -> Quaternion: + q = Quaternion() + q.z = math.sin(yaw / 2.0) + q.w = math.cos(yaw / 2.0) + return q + + +if __name__ == "__main__": + route_input = input( + f"\nGPS Waypoint Follower test script.\nChoose one of the following routes:\n{ROUTES}\n\nInput: " + ) + match route_input: + case "KB_TRUCK_PARKING_LOT" | 1: + route = Route.KB_TRUCK_PARKING_LOT + case "SIM_TIGHT_ALLEYS" | 2: + route = Route.SIM_TIGHT_ALLEYS + case _: + print("No valid route given. Defaulting to SIM_TIGHT_ALLEYS.") + route = Route.SIM_TIGHT_ALLEYS + + rclpy.init() + gps_waypoint_follower = GPSWaypointFollower() + + future = gps_waypoint_follower.send_goal(route) + rclpy.spin_until_future_complete(gps_waypoint_follower, future) + + gps_waypoint_follower.destroy_node() + rclpy.shutdown() From 4607651da0ad04d0bb00d54476e454c887d1afc9 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Tue, 3 Mar 2026 13:40:10 +0100 Subject: [PATCH 011/119] qol Signed-off-by: Peter Geurts --- .../test/test_gps_waypoint_follower.py | 65 +++++++++++++------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py index b5093d0da..c969b2dd3 100755 --- a/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py +++ b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py @@ -9,6 +9,7 @@ from geographic_msgs.msg import GeoPose from geometry_msgs.msg import Quaternion from nav2_msgs.action import FollowGPSWaypoints +from nav2_msgs.action._follow_gps_waypoints import FollowGPSWaypoints_FeedbackMessage from rclpy.action import ActionClient from rclpy.node import Node from rclpy.task import Future @@ -33,16 +34,16 @@ class Route(Enum): """Enum containing predefined routes for GPS Waypoint Follower. Attributes: - KB_TRUCK_PARKING_LOT: Route nearby Roboticalab entrance. + KB_TRUCK_PARKING_SPACE: Route nearby Roboticalab entrance. SIM_TIGHT_ALLEYS: Simulation route in Arnhem, with buildings close together. """ - KB_TRUCK_PARKING_LOT = auto() + KB_TRUCK_PARKING_SPACE = auto() SIM_TIGHT_ALLEYS = auto() ROUTES: dict[Route, list[GPSWaypoint]] = { - Route.KB_TRUCK_PARKING_LOT: [ + Route.KB_TRUCK_PARKING_SPACE: [ GPSWaypoint(51.966663, 5.940867), GPSWaypoint(51.966511, 5.940912), GPSWaypoint(51.966512, 5.940945), @@ -71,36 +72,54 @@ def __init__(self): self.ac = ActionClient( self, FollowGPSWaypoints, "/panther/follow_gps_waypoints" ) + self.current_wp = -1 - def send_goal(self, route: Route) -> Future: + def send_goal(self, route: Route) -> None: """Sends a Route to the nav2 action server. Args: route (Route): predefined set of waypoints to send. - - Returns: - Future: future that can be awaited by rclpy. """ self.ac.wait_for_server() waypoints = ROUTES[route] goal_msg = FollowGPSWaypoints.Goal() goal_msg.gps_poses = [self._to_gps_pose(wp) for wp in waypoints] - print(f"goal: {goal_msg}") - future = self.ac.send_goal_async(goal_msg, feedback_callback=self.cb_feedback) - future.add_done_callback(self.cb_result) - return future + self._send_goal_future = self.ac.send_goal_async( + goal_msg, feedback_callback=self.cb_feedback + ) + self._send_goal_future.add_done_callback(self.cb_goal_response) + + def cb_goal_response(self, future: Future) -> None: + """Callback to indicate whether goal is accepted or not. + + Args: + future (Future): future containing the goal response. + """ + gh = future.result() + if gh is None: + self.get_logger().info("Received None goal response.") + return + + if not gh.accepted: + self.get_logger().info("Goal rejected.") + return + + self.get_logger().info("Goal accepted!") + self._get_result_future = gh.get_result_async() + self._get_result_future.add_done_callback(self.cb_result) - def cb_feedback(self, feedback_msg: FollowGPSWaypoints.Feedback) -> None: + def cb_feedback(self, feedback_msg: FollowGPSWaypoints_FeedbackMessage) -> None: """Callback for feedback from the action server. Args: - feedback_msg (FollowGPSWaypoints.Feedback): feedback indicating current waypoint being followed. + feedback_msg (FollowGPSWaypoints_FeedbackMessage): feedback message containing current waypoint being followed. """ - self.get_logger().info( - f"Now navigating to waypoint {feedback_msg.current_waypoint}." - ) + current_wp = feedback_msg.feedback.current_waypoint + if current_wp != self.current_wp: + self.get_logger().info(f"Now navigating to waypoint {current_wp}.") + self.current_wp = current_wp def cb_result(self, result_msg: FollowGPSWaypoints.Result) -> None: """Callback for result from the action server. @@ -130,22 +149,26 @@ def _yaw_to_quaternion(yaw: float) -> Quaternion: if __name__ == "__main__": route_input = input( - f"\nGPS Waypoint Follower test script.\nChoose one of the following routes:\n{ROUTES}\n\nInput: " + f"\nGPS Waypoint Follower test script. \ + \nChoose one of the following routes: \ + \n{[f'{i}: {k.name}' for i, k in enumerate(ROUTES)]} \ + \n\nInput: " ) match route_input: - case "KB_TRUCK_PARKING_LOT" | 1: - route = Route.KB_TRUCK_PARKING_LOT - case "SIM_TIGHT_ALLEYS" | 2: + case "KB_TRUCK_PARKING_SPACE" | "0": + route = Route.KB_TRUCK_PARKING_SPACE + case "SIM_TIGHT_ALLEYS" | "1": route = Route.SIM_TIGHT_ALLEYS case _: print("No valid route given. Defaulting to SIM_TIGHT_ALLEYS.") route = Route.SIM_TIGHT_ALLEYS + print(f"Sending route {route.name}.") rclpy.init() gps_waypoint_follower = GPSWaypointFollower() future = gps_waypoint_follower.send_goal(route) - rclpy.spin_until_future_complete(gps_waypoint_follower, future) + rclpy.spin(gps_waypoint_follower) gps_waypoint_follower.destroy_node() rclpy.shutdown() From bab209fc14291293e53ef26ebaa789d8d927a4c3 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Tue, 3 Mar 2026 13:44:23 +0100 Subject: [PATCH 012/119] qol Signed-off-by: Peter Geurts --- .../alliander_nav2/test/test_gps_waypoint_follower.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py index c969b2dd3..aff24e07d 100755 --- a/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py +++ b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py @@ -121,12 +121,17 @@ def cb_feedback(self, feedback_msg: FollowGPSWaypoints_FeedbackMessage) -> None: self.get_logger().info(f"Now navigating to waypoint {current_wp}.") self.current_wp = current_wp - def cb_result(self, result_msg: FollowGPSWaypoints.Result) -> None: + def cb_result(self, future: Future) -> None: """Callback for result from the action server. Args: - result_msg (FollowGPSWaypoints.Result): message indicating statistics of operation. + result_msg (Future): future containing result of GPS waypoint following. """ + result_msg = future.result() + if result_msg is None: + self.get_logger().info("No result received.") + return + self.get_logger().info( f"Completed waypoints.\nMissed waypoints: {result_msg.missed_waypoints}\nError msg: {result_msg.error_msg}" ) From edcd23271def6ebec288248ee969e5fc1c07c81f Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Tue, 3 Mar 2026 14:02:08 +0100 Subject: [PATCH 013/119] allow partial planning Signed-off-by: Peter Geurts --- .../config/nav2/planner_server.yaml | 1 + .../test/test_gps_waypoint_follower.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/planner_server.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/planner_server.yaml index 1735b02a2..cbe41b1f1 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/planner_server.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/planner_server.yaml @@ -7,6 +7,7 @@ planner_server: ros__parameters: planner_plugins: ["GridBased"] + allow_partial_planning: True GridBased: plugin: "nav2_smac_planner::SmacPlanner2D" diff --git a/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py index aff24e07d..321758d37 100755 --- a/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py +++ b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py @@ -73,6 +73,7 @@ def __init__(self): self, FollowGPSWaypoints, "/panther/follow_gps_waypoints" ) self.current_wp = -1 + self.done = False def send_goal(self, route: Route) -> None: """Sends a Route to the nav2 action server. @@ -125,16 +126,21 @@ def cb_result(self, future: Future) -> None: """Callback for result from the action server. Args: - result_msg (Future): future containing result of GPS waypoint following. + future (Future): future containing result of GPS waypoint following. """ result_msg = future.result() + print(result_msg) if result_msg is None: self.get_logger().info("No result received.") return + result = result_msg.result self.get_logger().info( - f"Completed waypoints.\nMissed waypoints: {result_msg.missed_waypoints}\nError msg: {result_msg.error_msg}" + f"Completed waypoints.\ + \nMissed waypoints: {result.missed_waypoints} \ + \nError msg: {result.error_msg}" ) + self.done = True @staticmethod def _to_gps_pose(wp: GPSWaypoint) -> GeoPose: @@ -167,13 +173,14 @@ def _yaw_to_quaternion(yaw: float) -> Quaternion: case _: print("No valid route given. Defaulting to SIM_TIGHT_ALLEYS.") route = Route.SIM_TIGHT_ALLEYS - print(f"Sending route {route.name}.") + print(f"Sending route {route.name} ({len(ROUTES[route])} waypoints).") rclpy.init() gps_waypoint_follower = GPSWaypointFollower() future = gps_waypoint_follower.send_goal(route) - rclpy.spin(gps_waypoint_follower) + while not gps_waypoint_follower.done: + rclpy.spin_once(gps_waypoint_follower) gps_waypoint_follower.destroy_node() rclpy.shutdown() From dea82f8c43f2920006128b86b837f77582fb9f4c Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Wed, 4 Mar 2026 10:05:34 +0100 Subject: [PATCH 014/119] qol Signed-off-by: Peter Geurts --- .../src/alliander_nav2/test/test_gps_waypoint_follower.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py index 321758d37..0b2ead4e2 100755 --- a/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py +++ b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py @@ -85,6 +85,7 @@ def send_goal(self, route: Route) -> None: waypoints = ROUTES[route] goal_msg = FollowGPSWaypoints.Goal() + goal_msg.number_of_loops = 5 goal_msg.gps_poses = [self._to_gps_pose(wp) for wp in waypoints] self._send_goal_future = self.ac.send_goal_async( @@ -129,7 +130,6 @@ def cb_result(self, future: Future) -> None: future (Future): future containing result of GPS waypoint following. """ result_msg = future.result() - print(result_msg) if result_msg is None: self.get_logger().info("No result received.") return From ee7575537db656693dda6fb22b11d7b1e03d8e23 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 5 Mar 2026 16:42:01 +0100 Subject: [PATCH 015/119] added rviz.yml option to start.py, useful for visualizing (live) ros data on your laptop Signed-off-by: Peter Geurts --- .../src/alliander_gazebo/worlds/world.sdf | 2 +- start.py | 26 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/alliander_gazebo/src/alliander_gazebo/worlds/world.sdf b/alliander_gazebo/src/alliander_gazebo/worlds/world.sdf index 165e50657..16c8b18a5 100644 --- a/alliander_gazebo/src/alliander_gazebo/worlds/world.sdf +++ b/alliander_gazebo/src/alliander_gazebo/worlds/world.sdf @@ -7,7 +7,7 @@ SPDX-License-Identifier: Apache-2.0 - 0.0025 + 0.004 1.0 diff --git a/start.py b/start.py index 2719a330e..c2b416b74 100755 --- a/start.py +++ b/start.py @@ -70,6 +70,7 @@ def __init__(self) -> None: self.dev = False self.gazebo_ui = False self.joystick = False + self.rviz_yaml = False self.changed_packages = utils.get_changed_packages() @@ -397,6 +398,11 @@ def create_compose( # noqa: PLR0912 } self.write_compose(output_file, content) + + if self.rviz_yaml: + rviz_content = {"services": {}} + self.add_service(rviz_content, "visualization") + self.write_compose("rviz.yml", rviz_content) return list(services.keys()) def run_compose(self) -> int: @@ -510,6 +516,20 @@ def run_compose(self) -> int: help="Add this flag to enable joystick control for arm and/or vehicle platforms.", ) + parser.add_argument( + "--rviz", + required=False, + action="store_true", + help="Add this flag to createan additional Rviz config in rviz.yml. You still need to specify platforms.", + ) + + parser.add_argument( + "--no-run", + required=False, + action="store_true", + help="Add this flag if you only want to create the YML files, but not run them.", + ) + # Parse arguments: args = parser.parse_args() compose = Compose() @@ -523,6 +543,7 @@ def run_compose(self) -> int: compose.simulator = not args.hardware compose.visualization = args.visualization compose.joystick = args.joystick + compose.rviz_yaml = args.rviz compose.mode = "configuration" elif isinstance(args.pytest, list): arguments = " " + " ".join(args.pytest) @@ -546,5 +567,6 @@ def run_compose(self) -> int: compose.create_compose(arguments=arguments) # Spin up containers: - ret = compose.run_compose() - sys.exit(ret) + if not args.no_run: + ret = compose.run_compose() + sys.exit(ret) From 9d0e854c1ce992ce87103bd9cc6d7a5628384d04 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Mon, 9 Mar 2026 13:50:16 +0100 Subject: [PATCH 016/119] added odometry to rviz; changed map to satellite tiles from google; remapped topic in odometry Signed-off-by: Peter Geurts --- .../src/alliander_gps/launch/gps.launch.py | 5 ++++- .../alliander_visualization/rviz.py | 21 ++++++++++++++++++- .../alliander_visualization/tool_manager.py | 1 + 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/alliander_gps/src/alliander_gps/launch/gps.launch.py b/alliander_gps/src/alliander_gps/launch/gps.launch.py index e0e5ce3c5..2434edc22 100644 --- a/alliander_gps/src/alliander_gps/launch/gps.launch.py +++ b/alliander_gps/src/alliander_gps/launch/gps.launch.py @@ -64,7 +64,7 @@ def launch_setup(context: LaunchContext) -> list: ("imu", f"/{gps_config.parent.namespace}/imu/data"), ( "odometry/filtered", - f"/{gps_config.parent.namespace}/odometry/filtered", + f"/{gps_config.parent.namespace}/odometry/global", ), ], ) @@ -121,6 +121,9 @@ def launch_setup(context: LaunchContext) -> list: ), } ], + remappings=[ + ("odometry/filtered", f"/{gps_config.parent.namespace}/odometry/global"), + ], ) return [ diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py index 4c6baf7e8..e21abb165 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py @@ -2,6 +2,8 @@ # # SPDX-License-Identifier: Apache-2.0 +from os import stat + import yaml from alliander_utilities.ros_utils import get_file_path, get_yaml @@ -280,6 +282,23 @@ def add_path(topic: str) -> None: } ) + @staticmethod + def add_odometry(topic: str) -> None: + """Add an odometry marker to the RViz configuration. + + Args: + topic (str): The odometry topic. + """ + Rviz.displays.append( + { + "Enabled": True, + "Class": "rviz_default_plugins/Odometry", + "Name": topic, + "Topic": {"Value": topic}, + "Keep": 1, + } + ) + @staticmethod def add_polygon(topic: str) -> None: """Add a polygon to the RViz configuration. @@ -325,7 +344,7 @@ def add_satellite(topic: str) -> None: "Enabled": True, "Class": "rviz_satellite/AerialMap", "Name": topic, - "Object URI": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "Object URI": "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", "Topic": { "Value": topic, }, diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py index 04ab43ba8..9957d339f 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py @@ -130,6 +130,7 @@ def add_vehicle(platform: Vehicle) -> None: if nav2.navigation: Rviz.add_map(f"/{ns}/global_costmap/costmap") Rviz.add_map(f"/{ns}/local_costmap/costmap", "map", 0.3) + Rviz.add_odometry(f"/{ns}/odometry/filtered") Rviz.add_path(f"/{ns}/plan") Rviz.add_vehicle_trajectory(f"/{ns}/optimal_trajectory") Vizanti.add_button("Stop", f"/{ns}/waypoint_follower_controller/stop") From 61b1788d3764ca009f2c9ccd7b513ae7d3812c5f Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Tue, 10 Mar 2026 13:56:48 +0100 Subject: [PATCH 017/119] moved gps ekf/navsat params to own file Signed-off-by: Peter Geurts --- alliander_gps/alliander_gps.Dockerfile | 2 +- .../src/alliander_gps/CMakeLists.txt | 2 +- .../src/alliander_gps/config/ekf_global.yaml | 99 +++++++++++++++++++ .../config/navsat_transform.yaml | 16 +++ .../src/alliander_gps/launch/gps.launch.py | 72 ++++---------- .../src/alliander_nav2/launch/nav2.launch.py | 4 +- .../alliander_visualization/tool_manager.py | 2 +- pyproject.toml | 3 + uv.lock | 4 + 9 files changed, 148 insertions(+), 56 deletions(-) create mode 100644 alliander_gps/src/alliander_gps/config/ekf_global.yaml create mode 100644 alliander_gps/src/alliander_gps/config/navsat_transform.yaml diff --git a/alliander_gps/alliander_gps.Dockerfile b/alliander_gps/alliander_gps.Dockerfile index dae50858e..dfbba64df 100644 --- a/alliander_gps/alliander_gps.Dockerfile +++ b/alliander_gps/alliander_gps.Dockerfile @@ -25,7 +25,7 @@ RUN /$WORKDIR/colcon_build.sh # Install python dependencies: WORKDIR $WORKDIR COPY pyproject.toml/ /$WORKDIR/pyproject.toml -RUN uv sync \ +RUN uv sync --group alliander-gps \ && echo "export PYTHONPATH=\"$(dirname $(dirname $(uv python find)))/lib/python3.12/site-packages:\$PYTHONPATH\"" >> /root/.bashrc \ && echo "export PATH=\"$(dirname $(dirname $(uv python find)))/bin:\$PATH\"" >> /root/.bashrc diff --git a/alliander_gps/src/alliander_gps/CMakeLists.txt b/alliander_gps/src/alliander_gps/CMakeLists.txt index ab6492d52..142cbf15f 100644 --- a/alliander_gps/src/alliander_gps/CMakeLists.txt +++ b/alliander_gps/src/alliander_gps/CMakeLists.txt @@ -11,7 +11,7 @@ find_package(ament_cmake_python REQUIRED) # Shared folders: install( - DIRECTORY launch + DIRECTORY config launch DESTINATION share/${PROJECT_NAME} ) diff --git a/alliander_gps/src/alliander_gps/config/ekf_global.yaml b/alliander_gps/src/alliander_gps/config/ekf_global.yaml new file mode 100644 index 000000000..c68c2faec --- /dev/null +++ b/alliander_gps/src/alliander_gps/config/ekf_global.yaml @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 + +# Based on: https://github.com/husarion/husarion_ugv_ros/blob/ros2/husarion_ugv_localization/config/enu_localization_with_gps.yaml +ekf_global: + ros__parameters: + frequency: 20.0 + two_d_mode: true + + transform_time_offset: 0.0 + transform_timeout: 0.05 + + world_frame: map + map_frame: map + odom_frame: substitute_me + base_link_frame: substitute_me + publish_acceleration: false + + odom0: substitute_me + odom0_config: [false, false, false, + false, false, false, + true, true, false, + false, false, true, + false, false, false] + odom0_queue_size: 3 + odom0_nodelay: true + odom0_differential: false + odom0_relative: true + + odom1: substitute_me + odom1_config: [true, true, false, + false, false, false, + false, false, false, + false, false, false, + false, false, false] + odom1_queue_size: 2 + odom1_differential: false + odom1_relative: false + + imu0: substitute_me + imu0_config: [false, false, false, + false, false, true, + false, false, false, + false, false, true, + false, false, false] + imu0_queue_size: 3 + imu0_nodelay: true + imu0_differential: false + imu0_relative: false + imu0_remove_gravitational_acceleration: false + + use_control: true + stamped_control: true + control_timeout: 0.5 + control_config: [true, true, false, false, false, true] + acceleration_limits: [2.7, 1.5, 0.0, 0.0, 0.0, 5.7] # Values taken from WH01_controller.yaml and WH02_controller.yaml inside husarion_ugv_controller/config + + predict_to_current_time: true + + # Selected values ​​experimentally so as to ensure relatively fast convergence (values ​​should be about 10x higher than the sensor variance values) + dynamic_process_noise_covariance: true + process_noise_covariance: [5e-3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 5e-3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 5e-3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 3e-5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 3e-5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 3e-5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2e-5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2e-5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2e-5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2e-4, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2e-4, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2e-4, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5e-5, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5e-5, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5e-5] + + initial_state: [0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0] + + initial_estimate_covariance: [1e-9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 1e-9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 1e-9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 1e-9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 1e-9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 1e5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-9, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-9, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-9, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-9, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-9, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-9] diff --git a/alliander_gps/src/alliander_gps/config/navsat_transform.yaml b/alliander_gps/src/alliander_gps/config/navsat_transform.yaml new file mode 100644 index 000000000..6a0814b15 --- /dev/null +++ b/alliander_gps/src/alliander_gps/config/navsat_transform.yaml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 + +# Based on: https://github.com/husarion/husarion_ugv_ros/blob/ros2/husarion_ugv_localization/config/enu_localization_with_gps.yaml +navsat_transform: + navsat_transform: + ros__parameters: + delay: 3.0 + magnetic_declination_radians: 0.0 + yaw_offset: 0.0 + zero_altitude: true + broadcast_cartesian_transform: false + publish_filtered_gps: true + use_odometry_yaw: true + wait_for_datum: false diff --git a/alliander_gps/src/alliander_gps/launch/gps.launch.py b/alliander_gps/src/alliander_gps/launch/gps.launch.py index 2434edc22..c39fa9952 100644 --- a/alliander_gps/src/alliander_gps/launch/gps.launch.py +++ b/alliander_gps/src/alliander_gps/launch/gps.launch.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 import itertools +from alliander_utilities.adapted_yaml import AdaptedYaml from alliander_utilities.config_objects import GPS from alliander_utilities.launch_argument import LaunchArgument from alliander_utilities.launch_utils import SKIP, state_publisher_node, static_tf_node @@ -51,15 +52,29 @@ def launch_setup(context: LaunchContext) -> list: {"platform_config": gps_config.to_str()}, ) + ekf_global_params = AdaptedYaml( + get_file_path("alliander_gps", ["config"], "ekf_global.yaml"), + { + "odom_frame": f"{gps_config.parent.namespace}/odom", + "base_link_frame": f"{gps_config.parent.namespace}/base_footprint", + "odom0": f"/{gps_config.parent.namespace}/odometry/wheels", + "odom1": f"/{gps_config.namespace}/odometry/gps", + "imu0": f"/{gps_config.parent.namespace}/imu/data", + }, + root_key=gps_config.parent.namespace, + ) + navsat_transform_params = AdaptedYaml( + get_file_path("alliander_gps", ["config"], "navsat_transform.yaml"), + {}, + root_key=gps_config.parent.namespace, + ) + navsat_transform = Node( package="robot_localization", executable="navsat_transform_node", + name="navsat_transform", namespace=gps_config.namespace, - parameters=[ - { - "publish_filtered_gps": True, - } - ], + parameters=[navsat_transform_params.file], remappings=[ ("imu", f"/{gps_config.parent.namespace}/imu/data"), ( @@ -75,52 +90,7 @@ def launch_setup(context: LaunchContext) -> list: executable="ekf_node", name="ekf_global", namespace=gps_config.parent.namespace, - parameters=[ - { - "two_d_mode": True, - "publish_tf": True, - "world_frame": "map", - "map_frame": "map", - "odom_frame": f"{gps_config.parent.namespace}/odom", - "base_link_frame": f"{gps_config.parent.namespace}/base_footprint", - "odom0": f"/{gps_config.parent.namespace}/odometry/wheels", - "odom0_config": list( - itertools.chain.from_iterable( - [ - [F, F, F], # [x_pos, y_pos, z_pos] - [F, F, F], # [roll, pitch, yaw] - [T, T, T], # [x_vel, y_vel, z_vel] - [F, F, T], # [roll_rate, pitch_rate, yaw_rate] - [F, F, F], # [x_accel, y_accel, z_accel] - ] - ) - ), - "odom1": f"/{gps_config.namespace}/odometry/gps", - "odom1_config": list( - itertools.chain.from_iterable( - [ - [T, T, F], # [x_pos, y_pos, z_pos] - [F, F, F], # [roll, pitch, yaw] - [F, F, F], # [x_vel, y_vel, z_vel] - [F, F, F], # [roll_rate, pitch_rate, yaw_rate] - [F, F, F], # [x_accel, y_accel, z_accel] - ] - ) - ), - "imu0": f"/{gps_config.parent.namespace}/imu/data", - "imu0_config": list( - itertools.chain.from_iterable( - [ - [F, F, F], # [x_pos, y_pos, z_pos] - [F, F, T], # [roll, pitch, yaw] - [F, F, F], # [x_vel, y_vel, z_vel] - [F, F, F], # [roll_rate, pitch_rate, yaw_rate] - [F, F, F], # [x_accel, y_accel, z_accel] - ] - ) - ), - } - ], + parameters=[ekf_global_params.file], remappings=[ ("odometry/filtered", f"/{gps_config.parent.namespace}/odometry/global"), ], diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index ba32cf48b..801b94d91 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -139,7 +139,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 controller_server_params = AdaptedYaml( get_file_path("alliander_nav2", ["config", "nav2"], "controller_server.yaml"), - {"odom_topic": f"/{namespace_vehicle}/odom"}, + {"odom_topic": f"/{namespace_vehicle}/odometry/filtered"}, root_key=namespace_vehicle, ) @@ -169,7 +169,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 "alliander_nav2", ["config", "nav2"], "behavior_tree.xml" ), "robot_base_frame": f"{namespace_vehicle}/base_footprint", - "odom_topic": f"/{namespace_vehicle}/odom", + "odom_topic": f"/{namespace_vehicle}/odometry/filtered", }, root_key=namespace_vehicle, ) diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py index 9957d339f..ab3641c92 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py @@ -130,7 +130,7 @@ def add_vehicle(platform: Vehicle) -> None: if nav2.navigation: Rviz.add_map(f"/{ns}/global_costmap/costmap") Rviz.add_map(f"/{ns}/local_costmap/costmap", "map", 0.3) - Rviz.add_odometry(f"/{ns}/odometry/filtered") + Rviz.add_odometry(f"/{ns}/odometry/global") Rviz.add_path(f"/{ns}/plan") Rviz.add_vehicle_trajectory(f"/{ns}/optimal_trajectory") Vizanti.add_button("Stop", f"/{ns}/waypoint_follower_controller/stop") diff --git a/pyproject.toml b/pyproject.toml index fdd36d61d..680dfea9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,9 @@ alliander-gazebo = [ "scipy>=1.17.0", "xmltodict>=1.0.2", ] +alliander-gps = [ + "pydantic>=2.12.5", +] alliander-moveit = [ "xmltodict>=1.0.2", ] diff --git a/uv.lock b/uv.lock index 5d43270b4..1893cfb35 100644 --- a/uv.lock +++ b/uv.lock @@ -98,6 +98,9 @@ alliander-gazebo = [ { name = "scipy" }, { name = "xmltodict" }, ] +alliander-gps = [ + { name = "pydantic" }, +] alliander-moveit = [ { name = "xmltodict" }, ] @@ -157,6 +160,7 @@ alliander-gazebo = [ { name = "scipy", specifier = ">=1.17.0" }, { name = "xmltodict", specifier = ">=1.0.2" }, ] +alliander-gps = [{ name = "pydantic", specifier = ">=2.12.5" }] alliander-moveit = [{ name = "xmltodict", specifier = ">=1.0.2" }] alliander-nav2 = [ { name = "numpy", specifier = ">=2.4.1" }, From ce87a628226354fc218ff690b80ec9b67b45783f Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Tue, 10 Mar 2026 14:11:39 +0100 Subject: [PATCH 018/119] noqa on method count in Rviz Signed-off-by: Peter Geurts --- alliander_gps/src/alliander_gps/launch/gps.launch.py | 2 -- .../alliander_visualization/alliander_visualization/rviz.py | 5 +---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/alliander_gps/src/alliander_gps/launch/gps.launch.py b/alliander_gps/src/alliander_gps/launch/gps.launch.py index c39fa9952..552c5a742 100644 --- a/alliander_gps/src/alliander_gps/launch/gps.launch.py +++ b/alliander_gps/src/alliander_gps/launch/gps.launch.py @@ -1,8 +1,6 @@ # SPDX-FileCopyrightText: Alliander N. V. # # SPDX-License-Identifier: Apache-2.0 -import itertools - from alliander_utilities.adapted_yaml import AdaptedYaml from alliander_utilities.config_objects import GPS from alliander_utilities.launch_argument import LaunchArgument diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py index e21abb165..1f416bd8e 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/rviz.py @@ -1,14 +1,11 @@ # SPDX-FileCopyrightText: Alliander N. V. # # SPDX-License-Identifier: Apache-2.0 - -from os import stat - import yaml from alliander_utilities.ros_utils import get_file_path, get_yaml -class Rviz: +class Rviz: # noqa PLR0904 """A class to dynammically manage the RViz configuration. Attributes: From a28761043a26c5c92766564ef15ae5ccbf967548 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Tue, 10 Mar 2026 16:03:54 +0100 Subject: [PATCH 019/119] following panther docs enu_localization_with_gps more Signed-off-by: Peter Geurts --- .../src/alliander_gps/config/ekf_global.yaml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/alliander_gps/src/alliander_gps/config/ekf_global.yaml b/alliander_gps/src/alliander_gps/config/ekf_global.yaml index c68c2faec..4ef9cb6e5 100644 --- a/alliander_gps/src/alliander_gps/config/ekf_global.yaml +++ b/alliander_gps/src/alliander_gps/config/ekf_global.yaml @@ -6,6 +6,7 @@ ekf_global: ros__parameters: frequency: 20.0 + sensor_timeout: 0.05 two_d_mode: true transform_time_offset: 0.0 @@ -15,14 +16,15 @@ ekf_global: map_frame: map odom_frame: substitute_me base_link_frame: substitute_me + publish_tf: true publish_acceleration: false odom0: substitute_me odom0_config: [false, false, false, - false, false, false, - true, true, false, - false, false, true, - false, false, false] + false, false, false, + true, true, false, + false, false, true, + false, false, false] odom0_queue_size: 3 odom0_nodelay: true odom0_differential: false @@ -30,10 +32,10 @@ ekf_global: odom1: substitute_me odom1_config: [true, true, false, - false, false, false, - false, false, false, - false, false, false, - false, false, false] + false, false, false, + false, false, false, + false, false, false, + false, false, false] odom1_queue_size: 2 odom1_differential: false odom1_relative: false From 04e8862645bccba43730e2ae9bbfe177af089c52 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Tue, 31 Mar 2026 09:43:25 +0200 Subject: [PATCH 020/119] spawn panther at an angle Signed-off-by: Peter Geurts --- .nvim/dev/build.sh | 15 +++++++++++++++ .nvim/dev/build_setup.sh | 13 +++++++++++++ .../src_py/alliander_gui.py | 2 +- predefined_configurations.py | 2 +- 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100755 .nvim/dev/build.sh create mode 100755 .nvim/dev/build_setup.sh diff --git a/.nvim/dev/build.sh b/.nvim/dev/build.sh new file mode 100755 index 000000000..66f1a42fd --- /dev/null +++ b/.nvim/dev/build.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +set -e + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) +echo $SCRIPT_DIR + +docker build \ + --platform linux/amd64 \ + -f $SCRIPT_DIR../../../.devcontainer/dev/Dockerfile \ + -t allianderrobotics/dev \ + $SCRIPT_DIR/../../ diff --git a/.nvim/dev/build_setup.sh b/.nvim/dev/build_setup.sh new file mode 100755 index 000000000..9271ee4fc --- /dev/null +++ b/.nvim/dev/build_setup.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +set +u + +source /opt/ros/jazzy/setup.bash +source /alliander/ros/install/setup.bash +export PYTHONPATH="/alliander/.venv/lib/python3.12/site-packages:$PYTHONPATH" +export PATH="/alliander/.venv/bin:$PATH" + +set -u diff --git a/alliander_visualization/src/alliander_visualization/src_py/alliander_gui.py b/alliander_visualization/src/alliander_visualization/src_py/alliander_gui.py index aa3c7862f..4ec23efaf 100755 --- a/alliander_visualization/src/alliander_visualization/src_py/alliander_gui.py +++ b/alliander_visualization/src/alliander_visualization/src_py/alliander_gui.py @@ -103,7 +103,7 @@ def __init__(self): self.ui = UserInterface(self) connected = 0 - controllers = 3 * [None] + controllers: list[ArmControl | VehicleControl | None] = [None, None, None] for platform in platform_list.platforms: if connected == COLS: self.get_logger().warn( diff --git a/predefined_configurations.py b/predefined_configurations.py index 644601da3..796c4db72 100644 --- a/predefined_configurations.py +++ b/predefined_configurations.py @@ -235,7 +235,7 @@ def config_panther_lidar_navigation(self) -> None: # noqa: D102 @register_configuration("panther_gps_navigation") def config_panther_gps_navigation(self) -> None: # noqa: D102 - vehicle = Vehicle("panther", (0, 0, 0.2)) + vehicle = Vehicle("panther", (0, 0, 0.2), (0, 0, 45)) vehicle.nav2_config.controller = "mppi" vehicle.nav2_config.navigation = True vehicle.nav2_config.gps = True From 1183a71719293187e280069b1ff8814211224cc7 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Tue, 31 Mar 2026 14:17:44 +0200 Subject: [PATCH 021/119] docs Signed-off-by: Peter Geurts --- docs/content/nav2.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/content/nav2.md b/docs/content/nav2.md index f7d3e0a22..a22cdd8f3 100644 --- a/docs/content/nav2.md +++ b/docs/content/nav2.md @@ -12,6 +12,12 @@ According to the official [website](https://docs.nav2.org/): This documentation shows Nav2 components that are used in this repository, sometimes with some additional explanation about implementation choices. +## Planner +We choose to implement, for our Husarion platforms, the `SmacPlanner2D` planner. This because it is a standard planner in Nav2, and does not need a minimum turning radius. The `SmacPlannerHybrid` needs a minimum turning radius as large as the costmap resolution, a constraint that the Husarion platforms do not have. Using the Hybrid-A* planner sometimes results in no path being found, even though the Husarion robot is able to physically move to the goal pose. Using the classic 2D A* planner resolves this. + +## Controller +With a relatively simple planner, we choose MPPI as the Husarion robots` controller. This because it is good for dynamic obstacle avoidance, produces smooth commands, and supports GPU acceleration. + ## Behaviour Tree We implement a behaviour tree that was originally inspired by Nav2's `navigate_to_pose_w_replanning_and_recovery.xml`. See below our tree written out: @@ -50,4 +56,4 @@ MainTree └── BackUp (0.30m @ 0.15 m/s) ``` -In short, this behaviour tree performs autonomous navigation with constant replanning and recovery back-ups. Initially it tries to plan and follow a path, pausing navigation when the GPS signal is not sufficient enough. If either planning or path following fails, there are some local recovery sequences installed (e.g., clearing the local costmap). If navigation still fails, it moves on to a global recovery sequence, that can clear costmaps, spin the vehicle, wait for a period, or move the vehicle back a bit before retrying navigation. \ No newline at end of file +In short, this behaviour tree performs autonomous navigation with constant replanning and recovery back-ups. Initially it tries to plan and follow a path, pausing navigation when the GPS signal is not sufficient enough. If either planning or path following fails, there are some local recovery sequences installed (e.g., clearing the local costmap). If navigation still fails, it moves on to a global recovery sequence, that can clear costmaps, spin the vehicle, wait for a period, or move the vehicle back a bit before retrying navigation. From adc50b04e89002f76f73ed28549668049c889040 Mon Sep 17 00:00:00 2001 From: Peter Geurts <25298249+geurto@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:19:13 +0200 Subject: [PATCH 022/119] Update start.py Signed-off-by: Peter Geurts <25298249+geurto@users.noreply.github.com> Signed-off-by: Peter Geurts --- start.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/start.py b/start.py index c2b416b74..5a5f32232 100755 --- a/start.py +++ b/start.py @@ -520,7 +520,7 @@ def run_compose(self) -> int: "--rviz", required=False, action="store_true", - help="Add this flag to createan additional Rviz config in rviz.yml. You still need to specify platforms.", + help="Add this flag to create an additional Rviz config in rviz.yml. You still need to specify platforms.", ) parser.add_argument( From f2a749bf63cfa4a6fd8e846a9b63f36d495c7cbe Mon Sep 17 00:00:00 2001 From: Rosalie Date: Mon, 30 Mar 2026 10:47:12 +0200 Subject: [PATCH 023/119] Test only nav2 navigation, replace raise with pytest.fail Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 2 +- .../tests/test_nav2_navigation_lidar.py | 40 ++++++++++++---- alliander_tests/src/alliander_tests/utils.py | 46 +++++++++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 119324d40..9bf7c73bf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -200,4 +200,4 @@ jobs: - name: Install dependencies run: pip install pyyaml mashumaro - name: Run tests in alliander_tests container - run: python3 start.py --pytest-no-nvidia + run: python3 start.py --pytest-no-nvidia --mode all -k nav2_navigation_lidar diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index ab82209a5..db63c8b53 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -6,6 +6,7 @@ import sys import time +import pytest import rclpy from alliander_utilities.config_objects import Lidar, Vehicle, link from geometry_msgs.msg import PoseStamped, TransformStamped @@ -15,7 +16,7 @@ from tf2_ros.buffer import Buffer from tf2_ros.transform_listener import TransformListener -from ..utils import call_trigger_service, wait_for_subscriber +from ..utils import call_trigger_service, wait_for_node_active, wait_for_subscriber class _TestNavigationLidar: @@ -49,7 +50,7 @@ def test_goal_pose_lidar( start_time = time.time() while current_pose == TransformStamped(): - rclpy.spin_once(test_node, timeout_sec=0) + rclpy.spin_once(test_node, timeout_sec=0) # TODO: change to 0.1 maybe? with contextlib.suppress(TransformException): current_pose = tf_buffer.lookup_transform( "map", f"{self.platforms['vehicle'].namespace}/base_link", Time() @@ -64,9 +65,16 @@ def test_goal_pose_lidar( goal_pose.pose.position.y = current_pose.transform.translation.y goal_pose.pose.position.z = current_pose.transform.translation.z - publisher = test_node.create_publisher( - PoseStamped, f"/{self.platforms['vehicle'].namespace}/goal_pose", 10 - ) + publisher = test_node.create_publisher(PoseStamped, "/goal_pose", 10) + # wait_for_node_active( + # test_node, f"/{self.platforms['vehicle'].namespace}/bt_navigator", 20.0 + # ) + # wait_for_node_active( + # test_node, f"/{self.platforms['vehicle'].namespace}/planner_server", 20.0 + # ) + # wait_for_node_active( + # test_node, f"/{self.platforms['vehicle'].namespace}/controller_server", 20.0 + # ) wait_for_subscriber(publisher, timeout) publisher.publish(goal_pose) test_node.get_logger().info("Published goal pose for navigation.") @@ -84,10 +92,12 @@ def test_goal_pose_lidar( current_pose.transform.translation.x - goal_pose.pose.position.x ) if time.time() - start_time > timeout: - raise TimeoutError( + pytest.fail( f"Distance is {distance} while tolerance is {navigation_distance_tolerance}." ) + test_node.get_logger().info(f"Final TEST distance: {distance}") + # 4) Stop navigation, since the goal can be reached before the navigation is finished due to tolerance: assert call_trigger_service( test_node, @@ -96,8 +106,22 @@ def test_goal_pose_lidar( ) -for vehicle in ["panther", "lynx"]: - for lidar in ["velodyne", "ouster"]: +for vehicle in [ + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", +]: + for lidar in [ + "velodyne", + "ouster" + ]: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) diff --git a/alliander_tests/src/alliander_tests/utils.py b/alliander_tests/src/alliander_tests/utils.py index 37602f5b6..79bc7b807 100644 --- a/alliander_tests/src/alliander_tests/utils.py +++ b/alliander_tests/src/alliander_tests/utils.py @@ -13,6 +13,7 @@ from builtin_interfaces.msg import Duration from control_msgs.action import FollowJointTrajectory from launch_testing_ros.wait_for_topics import WaitForTopics +from lifecycle_msgs.srv import GetState from rclpy.action import ActionClient from rclpy.action.client import ClientGoalHandle from rclpy.client import Client @@ -68,6 +69,51 @@ def wait_for_subscriber(pub: Publisher, timeout: int) -> None: time.sleep(0.1) +def wait_for_node_active(node: Node, lifecycle_node_name: str, timeout: float) -> None: + """Wait for a subscriber to be ready for a given publisher. + + Args: + node (Node): The publisher to wait for. + lifecycle_node_name (str): The name of the lifecycle node to wait for. + timeout (float): The maximum time to wait for a subscriber in seconds. + + Raises: + TimeoutError: If no subscriber is found within the timeout period. + + """ + client = node.create_client(GetState, f"{lifecycle_node_name}/get_state") + + if not client.wait_for_service(timeout_sec=timeout): + raise TimeoutError(f"Service {lifecycle_node_name} not available") + + start_time = time.time() + + while True: + request = GetState.Request() + future = client.call_async(request) + + rclpy.spin_until_future_complete(node, future, timeout_sec=1.0) + + if future.result() is not None: + state_id = future.result().current_state.id + state_label = future.result().current_state.label + + node.get_logger().info( + f"{lifecycle_node_name} state: {state_label}" + ) + + active_state = 3 + if state_id == active_state: + return + + if time.time() - start_time > timeout: + raise TimeoutError( + f"{lifecycle_node_name} did not become ACTIVE" + ) + + time.sleep(0.5) + + def get_joint_position(namespace: str, joint: str, timeout: int) -> float: """Get the position of a joint from the joint states topic. From 4987820e147e4971b8ae52646e8ae684300e3072 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Mon, 30 Mar 2026 11:11:30 +0200 Subject: [PATCH 024/119] Add more logs Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_tests/tests/test_nav2_navigation_lidar.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index db63c8b53..3963e5940 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -92,10 +92,12 @@ def test_goal_pose_lidar( current_pose.transform.translation.x - goal_pose.pose.position.x ) if time.time() - start_time > timeout: + test_node.get_logger().info("FAILED NAV2") pytest.fail( f"Distance is {distance} while tolerance is {navigation_distance_tolerance}." ) + test_node.get_logger().info("SUCCESS NAV2") test_node.get_logger().info(f"Final TEST distance: {distance}") # 4) Stop navigation, since the goal can be reached before the navigation is finished due to tolerance: From a632d4d07e8d8f93eb7dd3c112e97ba5586844ba Mon Sep 17 00:00:00 2001 From: Rosalie Date: Mon, 30 Mar 2026 12:01:18 +0200 Subject: [PATCH 025/119] Try to trigger test error with old test Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_tests/tests/test_nav2_navigation_lidar.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 3963e5940..bd4f66ce7 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -93,7 +93,10 @@ def test_goal_pose_lidar( ) if time.time() - start_time > timeout: test_node.get_logger().info("FAILED NAV2") - pytest.fail( + # pytest.fail( + # f"Distance is {distance} while tolerance is {navigation_distance_tolerance}." + # ) + raise TimeoutError( f"Distance is {distance} while tolerance is {navigation_distance_tolerance}." ) From 782c3fcc606c5f8721b0edc2ccb689f5e99b58aa Mon Sep 17 00:00:00 2001 From: Rosalie Date: Mon, 30 Mar 2026 12:33:34 +0200 Subject: [PATCH 026/119] Managed to trigger timeout error so only testing this file is fine, go back to pytest.fail and trigger more often Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../tests/test_nav2_navigation_lidar.py | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index bd4f66ce7..97addb2e1 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -93,10 +93,7 @@ def test_goal_pose_lidar( ) if time.time() - start_time > timeout: test_node.get_logger().info("FAILED NAV2") - # pytest.fail( - # f"Distance is {distance} while tolerance is {navigation_distance_tolerance}." - # ) - raise TimeoutError( + pytest.fail( f"Distance is {distance} while tolerance is {navigation_distance_tolerance}." ) @@ -111,28 +108,27 @@ def test_goal_pose_lidar( ) -for vehicle in [ - "panther", - "lynx", - "panther", - "lynx", - "panther", - "lynx", - "panther", - "lynx", - "panther", - "lynx", -]: - for lidar in [ - "velodyne", - "ouster" - ]: +for i, vehicle in enumerate( + [ + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + ] +): + for lidar in ["velodyne", "ouster"]: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) vehicle_platform.nav2_config.navigation = True test_class = type( - f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation", + f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation{i}", (_TestNavigationLidar,), {"platforms": {"vehicle": vehicle_platform, "lidar": lidar_platform}}, ) From 8b11797438952e709786864b8698d60ccce7f5db Mon Sep 17 00:00:00 2001 From: Rosalie Date: Mon, 30 Mar 2026 13:56:05 +0200 Subject: [PATCH 027/119] Assert timeout instead of raising, and add wait_for_node_active Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../tests/test_nav2_navigation_lidar.py | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 97addb2e1..04ecc6d01 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -6,7 +6,6 @@ import sys import time -import pytest import rclpy from alliander_utilities.config_objects import Lidar, Vehicle, link from geometry_msgs.msg import PoseStamped, TransformStamped @@ -66,15 +65,15 @@ def test_goal_pose_lidar( goal_pose.pose.position.z = current_pose.transform.translation.z publisher = test_node.create_publisher(PoseStamped, "/goal_pose", 10) - # wait_for_node_active( - # test_node, f"/{self.platforms['vehicle'].namespace}/bt_navigator", 20.0 - # ) - # wait_for_node_active( - # test_node, f"/{self.platforms['vehicle'].namespace}/planner_server", 20.0 - # ) - # wait_for_node_active( - # test_node, f"/{self.platforms['vehicle'].namespace}/controller_server", 20.0 - # ) + wait_for_node_active( + test_node, f"/{self.platforms['vehicle'].namespace}/bt_navigator", 20.0 + ) + wait_for_node_active( + test_node, f"/{self.platforms['vehicle'].namespace}/planner_server", 20.0 + ) + wait_for_node_active( + test_node, f"/{self.platforms['vehicle'].namespace}/controller_server", 20.0 + ) wait_for_subscriber(publisher, timeout) publisher.publish(goal_pose) test_node.get_logger().info("Published goal pose for navigation.") @@ -82,23 +81,24 @@ def test_goal_pose_lidar( # 3) Wait until goal is reached within tolerance: start_time = time.time() distance: float = sys.float_info.max + timed_out = False + while distance > navigation_distance_tolerance: rclpy.spin_once(test_node, timeout_sec=0) with contextlib.suppress(TransformException): current_pose = tf_buffer.lookup_transform( "map", f"{self.platforms['vehicle'].namespace}/base_link", Time() ) - distance = abs( - current_pose.transform.translation.x - goal_pose.pose.position.x - ) - if time.time() - start_time > timeout: - test_node.get_logger().info("FAILED NAV2") - pytest.fail( - f"Distance is {distance} while tolerance is {navigation_distance_tolerance}." + distance = abs( + current_pose.transform.translation.x - goal_pose.pose.position.x ) + if time.time() - start_time > timeout: + timed_out = True + break - test_node.get_logger().info("SUCCESS NAV2") - test_node.get_logger().info(f"Final TEST distance: {distance}") + assert not timed_out, ( + f"Timeout: distance {distance} > tolerance {navigation_distance_tolerance}" + ) # 4) Stop navigation, since the goal can be reached before the navigation is finished due to tolerance: assert call_trigger_service( From be7cd8e2f6424a2a4a089ffe389d0c5268e10de4 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Mon, 30 Mar 2026 14:19:26 +0200 Subject: [PATCH 028/119] Update some linting and trigger test 50 times Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../alliander_tests/tests/test_nav2_navigation_lidar.py | 8 ++++---- alliander_tests/src/alliander_tests/utils.py | 8 ++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 04ecc6d01..6a6985922 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -66,13 +66,13 @@ def test_goal_pose_lidar( publisher = test_node.create_publisher(PoseStamped, "/goal_pose", 10) wait_for_node_active( - test_node, f"/{self.platforms['vehicle'].namespace}/bt_navigator", 20.0 + test_node, f"/{self.platforms['vehicle'].namespace}/bt_navigator", 10.0 ) wait_for_node_active( - test_node, f"/{self.platforms['vehicle'].namespace}/planner_server", 20.0 + test_node, f"/{self.platforms['vehicle'].namespace}/planner_server", 10.0 ) wait_for_node_active( - test_node, f"/{self.platforms['vehicle'].namespace}/controller_server", 20.0 + test_node, f"/{self.platforms['vehicle'].namespace}/controller_server", 10.0 ) wait_for_subscriber(publisher, timeout) publisher.publish(goal_pose) @@ -122,7 +122,7 @@ def test_goal_pose_lidar( "lynx", ] ): - for lidar in ["velodyne", "ouster"]: + for lidar in ["velodyne", "ouster", "velodyne", "ouster", "velodyne"]: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) diff --git a/alliander_tests/src/alliander_tests/utils.py b/alliander_tests/src/alliander_tests/utils.py index 79bc7b807..c84cd7c96 100644 --- a/alliander_tests/src/alliander_tests/utils.py +++ b/alliander_tests/src/alliander_tests/utils.py @@ -98,18 +98,14 @@ def wait_for_node_active(node: Node, lifecycle_node_name: str, timeout: float) - state_id = future.result().current_state.id state_label = future.result().current_state.label - node.get_logger().info( - f"{lifecycle_node_name} state: {state_label}" - ) + node.get_logger().info(f"{lifecycle_node_name} state: {state_label}") active_state = 3 if state_id == active_state: return if time.time() - start_time > timeout: - raise TimeoutError( - f"{lifecycle_node_name} did not become ACTIVE" - ) + raise TimeoutError(f"{lifecycle_node_name} did not become ACTIVE") time.sleep(0.5) From c28bce2e3eccc17e6afbfe70ae850685af1d710e Mon Sep 17 00:00:00 2001 From: Rosalie Date: Mon, 30 Mar 2026 15:45:00 +0200 Subject: [PATCH 029/119] Wait for panther/odom Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../tests/test_nav2_navigation_lidar.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 6a6985922..c88258be4 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -6,6 +6,7 @@ import sys import time +import pytest import rclpy from alliander_utilities.config_objects import Lidar, Vehicle, link from geometry_msgs.msg import PoseStamped, TransformStamped @@ -42,9 +43,23 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ - # 1) Obtain current pose in map frame: + # 0) Wait for valid TF tf_buffer = Buffer() TransformListener(tf_buffer, test_node) + start = time.time() + while True: + rclpy.spin_once(test_node, timeout_sec=0.1) + try: + tf_buffer.lookup_transform("odom", f"{self.platforms['vehicle'].namespace}/base_footprint", Time()) + break + except TransformException: + pass + if time.time() - start > timeout: + pytest.fail("panther/odom never appeared: odometry stack did not start") + + # 1) Obtain current pose in map frame: + # tf_buffer = Buffer() + # TransformListener(tf_buffer, test_node) current_pose = TransformStamped() start_time = time.time() From d7af84a89830439e1bb6bc849b326f30aaff4ede Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 31 Mar 2026 09:59:33 +0200 Subject: [PATCH 030/119] Add more logs Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../tests/test_nav2_navigation_lidar.py | 66 ++++++++++++++----- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index c88258be4..a39c21675 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -3,15 +3,17 @@ # SPDX-License-Identifier: Apache-2.0 import contextlib +import math import sys import time -import pytest import rclpy from alliander_utilities.config_objects import Lidar, Vehicle, link -from geometry_msgs.msg import PoseStamped, TransformStamped +from geometry_msgs.msg import PoseStamped, TransformStamped, TwistStamped +from nav_msgs.msg import Odometry from rclpy.node import Node from rclpy.time import Time +from sensor_msgs.msg import JointState from tf2_ros import TransformException # ty: ignore[unresolved-import] from tf2_ros.buffer import Buffer from tf2_ros.transform_listener import TransformListener @@ -43,23 +45,46 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ - # 0) Wait for valid TF - tf_buffer = Buffer() - TransformListener(tf_buffer, test_node) - start = time.time() - while True: - rclpy.spin_once(test_node, timeout_sec=0.1) - try: - tf_buffer.lookup_transform("odom", f"{self.platforms['vehicle'].namespace}/base_footprint", Time()) - break - except TransformException: - pass - if time.time() - start > timeout: - pytest.fail("panther/odom never appeared: odometry stack did not start") + # 0) Log callbacks + def joint_state_callback(msg: JointState) -> None: + joint_state: JointState = msg + if math.isnan(joint_state.position[0]): + test_node.get_logger().error("ERROR: joint state is NaN!") + + test_node.create_subscription( + JointState, + f"/{self.platforms['vehicle'].namespace}/joint_states", + joint_state_callback, + 10, + ) + + def odom_callback(msg: Odometry) -> None: + odom: Odometry = msg + if math.isnan(odom.pose.pose.position.x): + test_node.get_logger().error("ERROR: odom is NaN!") + + test_node.create_subscription( + Odometry, + f"/{self.platforms['vehicle'].namespace}/odom", + odom_callback, + 10, + ) + + def twist_callback(msg: TwistStamped) -> None: + twist: TwistStamped = msg + if math.isnan(twist.twist.linear.x): + test_node.get_logger().error("ERROR: cmd_vel is NaN!") + + test_node.create_subscription( + TwistStamped, + f"/{self.platforms['vehicle'].namespace}/cmd_vel", + twist_callback, + 10, + ) # 1) Obtain current pose in map frame: - # tf_buffer = Buffer() - # TransformListener(tf_buffer, test_node) + tf_buffer = Buffer() + TransformListener(tf_buffer, test_node) current_pose = TransformStamped() start_time = time.time() @@ -97,6 +122,7 @@ def test_goal_pose_lidar( start_time = time.time() distance: float = sys.float_info.max timed_out = False + last_log_time = 0.0 while distance > navigation_distance_tolerance: rclpy.spin_once(test_node, timeout_sec=0) @@ -107,10 +133,16 @@ def test_goal_pose_lidar( distance = abs( current_pose.transform.translation.x - goal_pose.pose.position.x ) + now = time.time() + if now - last_log_time >= 1.0: + test_node.get_logger().info(f"Distance to goal: {distance:.2f}m") + last_log_time = now if time.time() - start_time > timeout: timed_out = True break + test_node.get_logger().info(f"Final TEST distance to goal: {distance}.") + assert not timed_out, ( f"Timeout: distance {distance} > tolerance {navigation_distance_tolerance}" ) From 1c73c86bfdfc66d486223b109da0162e9b7532be Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 31 Mar 2026 10:49:01 +0200 Subject: [PATCH 031/119] Remove nav2 container startup Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_tests/tests/test_nav2_navigation_lidar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index a39c21675..f0f519e7e 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -173,7 +173,7 @@ def twist_callback(msg: TwistStamped) -> None: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) - vehicle_platform.nav2_config.navigation = True + # vehicle_platform.nav2_config.navigation = True test_class = type( f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation{i}", (_TestNavigationLidar,), From c62e198b9d0cccb5cc925afbc3b2996e58043d18 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 31 Mar 2026 12:55:55 +0200 Subject: [PATCH 032/119] Remove waits for nav2 nodes Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../tests/test_nav2_navigation_lidar.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index f0f519e7e..4869e5c8f 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -105,15 +105,15 @@ def twist_callback(msg: TwistStamped) -> None: goal_pose.pose.position.z = current_pose.transform.translation.z publisher = test_node.create_publisher(PoseStamped, "/goal_pose", 10) - wait_for_node_active( - test_node, f"/{self.platforms['vehicle'].namespace}/bt_navigator", 10.0 - ) - wait_for_node_active( - test_node, f"/{self.platforms['vehicle'].namespace}/planner_server", 10.0 - ) - wait_for_node_active( - test_node, f"/{self.platforms['vehicle'].namespace}/controller_server", 10.0 - ) + # wait_for_node_active( + # test_node, f"/{self.platforms['vehicle'].namespace}/bt_navigator", 10.0 + # ) + # wait_for_node_active( + # test_node, f"/{self.platforms['vehicle'].namespace}/planner_server", 10.0 + # ) + # wait_for_node_active( + # test_node, f"/{self.platforms['vehicle'].namespace}/controller_server", 10.0 + # ) wait_for_subscriber(publisher, timeout) publisher.publish(goal_pose) test_node.get_logger().info("Published goal pose for navigation.") From 923aac3cf0fa3893294e41f5bb1fd7151aaae84a Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 31 Mar 2026 13:18:11 +0200 Subject: [PATCH 033/119] Remove more nav2 dependencies and lower timeout time Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_tests/tests/test_nav2_navigation_lidar.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 4869e5c8f..379b94e7d 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -45,6 +45,7 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ + timeout = 5 # Lower timeout for testing purposes # 0) Log callbacks def joint_state_callback(msg: JointState) -> None: @@ -114,7 +115,7 @@ def twist_callback(msg: TwistStamped) -> None: # wait_for_node_active( # test_node, f"/{self.platforms['vehicle'].namespace}/controller_server", 10.0 # ) - wait_for_subscriber(publisher, timeout) + # wait_for_subscriber(publisher, timeout) publisher.publish(goal_pose) test_node.get_logger().info("Published goal pose for navigation.") From 3b5131db2d04d1bd3055384987187e025bf518dc Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 31 Mar 2026 14:38:29 +0200 Subject: [PATCH 034/119] Wait for all nodes to be quit before starting containers Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../tests/test_nav2_navigation_lidar.py | 6 +++--- conftest.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 379b94e7d..fdb30acc1 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -45,7 +45,7 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ - timeout = 5 # Lower timeout for testing purposes + # timeout = 5 # Lower timeout for testing purposes # 0) Log callbacks def joint_state_callback(msg: JointState) -> None: @@ -115,7 +115,7 @@ def twist_callback(msg: TwistStamped) -> None: # wait_for_node_active( # test_node, f"/{self.platforms['vehicle'].namespace}/controller_server", 10.0 # ) - # wait_for_subscriber(publisher, timeout) + wait_for_subscriber(publisher, timeout) publisher.publish(goal_pose) test_node.get_logger().info("Published goal pose for navigation.") @@ -174,7 +174,7 @@ def twist_callback(msg: TwistStamped) -> None: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) - # vehicle_platform.nav2_config.navigation = True + vehicle_platform.nav2_config.navigation = True test_class = type( f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation{i}", (_TestNavigationLidar,), diff --git a/conftest.py b/conftest.py index a7b836ec0..0c5d408ca 100644 --- a/conftest.py +++ b/conftest.py @@ -102,6 +102,23 @@ def control_class(request: SubRequest) -> Generator: if Configurations.mode != "all": skip_if_no_changes(services) pull_missing_images() + cprint("Check ros2 node list before starting containers.", "blue") + + active_nodes = subprocess.check_output( + ["ros2", "node", "list"], + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + while active_nodes != bytes(): + active_nodes = subprocess.check_output( + ["ros2", "node", "list"], + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + cprint(f"Waiting for the following nodes to exit: {active_nodes.decode("utf-8")}", "red") + time.sleep(1) + cprint("No more nodes active.", "blue") + process = start_containers(services) yield From ed602956b1e97612841078d893c505ce06ed9569 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 31 Mar 2026 14:57:02 +0200 Subject: [PATCH 035/119] Add docker login to tests Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9bf7c73bf..201142270 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -192,6 +192,11 @@ jobs: with: fetch-depth: 0 ref: ${{ env.BRANCH_NAME }} + - name: Docker login + uses: docker/login-action@v4 + with: + username: ${{ inputs.DOCKER_USERNAME }} + password: ${{ inputs.DOCKER_TOKEN }} - name: Set up python3 uses: actions/setup-python@v6 with: From 25aa9bb0898df87d6f673f9aea7b176f9f6184d3 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 31 Mar 2026 14:59:25 +0200 Subject: [PATCH 036/119] Refactor inputs to secrets Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 201142270..bc33995da 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -195,8 +195,8 @@ jobs: - name: Docker login uses: docker/login-action@v4 with: - username: ${{ inputs.DOCKER_USERNAME }} - password: ${{ inputs.DOCKER_TOKEN }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} - name: Set up python3 uses: actions/setup-python@v6 with: From f7198745547529a4cbff8e9391cb9e361bf2d244 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 1 Apr 2026 10:49:16 +0200 Subject: [PATCH 037/119] Final test on lidar specifically before switching back to normal test flow Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/main.yml | 5 ++ .../tests/test_nav2_navigation_gps.py | 18 ++++- .../tests/test_nav2_navigation_lidar.py | 77 ++----------------- alliander_tests/src/alliander_tests/utils.py | 42 ---------- conftest.py | 35 +++++---- 5 files changed, 46 insertions(+), 131 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a70d5fe7f..a8d7c5a61 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -165,6 +165,11 @@ jobs: needs: [build-ubuntu-images-manifest, build-cuda-images-manifest] steps: - uses: actions/checkout@v6 + - name: Docker login + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} - name: Set up python3 uses: actions/setup-python@v6 with: diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index 45ffa156e..52f3b5b52 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -72,13 +72,25 @@ def callback(msg: NavSatFix) -> None: # 3) Wait until goal is reached within tolerance: start_time = time.time() distance: float = sys.float_info.max + timed_out = False + last_log_time = 0.0 + while distance > navigation_degree_tolerance: rclpy.spin_once(test_node, timeout_sec=0) distance = abs(current_nav_sat.latitude - goal_nav_sat.latitude) + now = time.time() + if now - last_log_time >= 1.0: + test_node.get_logger().info(f"Distance to goal: {distance}") + last_log_time = now if time.time() - start_time > timeout: - raise TimeoutError( - f"Distance is {distance} while tolerance is {navigation_degree_tolerance}." - ) + timed_out = True + break + + test_node.get_logger().info(f"Final distance to goal: {distance}.") + + assert not timed_out, ( + f"Timeout: distance {distance} > tolerance {navigation_degree_tolerance}" + ) # 4) Stop navigation, since the goal can be reached before the navigation is finished due to tolerance: assert call_trigger_service( diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index fdb30acc1..bd5f5f92a 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -3,22 +3,19 @@ # SPDX-License-Identifier: Apache-2.0 import contextlib -import math import sys import time import rclpy from alliander_utilities.config_objects import Lidar, Vehicle, link -from geometry_msgs.msg import PoseStamped, TransformStamped, TwistStamped -from nav_msgs.msg import Odometry +from geometry_msgs.msg import PoseStamped, TransformStamped from rclpy.node import Node from rclpy.time import Time -from sensor_msgs.msg import JointState from tf2_ros import TransformException # ty: ignore[unresolved-import] from tf2_ros.buffer import Buffer from tf2_ros.transform_listener import TransformListener -from ..utils import call_trigger_service, wait_for_node_active, wait_for_subscriber +from ..utils import call_trigger_service, wait_for_subscriber class _TestNavigationLidar: @@ -45,44 +42,6 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ - # timeout = 5 # Lower timeout for testing purposes - - # 0) Log callbacks - def joint_state_callback(msg: JointState) -> None: - joint_state: JointState = msg - if math.isnan(joint_state.position[0]): - test_node.get_logger().error("ERROR: joint state is NaN!") - - test_node.create_subscription( - JointState, - f"/{self.platforms['vehicle'].namespace}/joint_states", - joint_state_callback, - 10, - ) - - def odom_callback(msg: Odometry) -> None: - odom: Odometry = msg - if math.isnan(odom.pose.pose.position.x): - test_node.get_logger().error("ERROR: odom is NaN!") - - test_node.create_subscription( - Odometry, - f"/{self.platforms['vehicle'].namespace}/odom", - odom_callback, - 10, - ) - - def twist_callback(msg: TwistStamped) -> None: - twist: TwistStamped = msg - if math.isnan(twist.twist.linear.x): - test_node.get_logger().error("ERROR: cmd_vel is NaN!") - - test_node.create_subscription( - TwistStamped, - f"/{self.platforms['vehicle'].namespace}/cmd_vel", - twist_callback, - 10, - ) # 1) Obtain current pose in map frame: tf_buffer = Buffer() TransformListener(tf_buffer, test_node) @@ -90,7 +49,7 @@ def twist_callback(msg: TwistStamped) -> None: start_time = time.time() while current_pose == TransformStamped(): - rclpy.spin_once(test_node, timeout_sec=0) # TODO: change to 0.1 maybe? + rclpy.spin_once(test_node, timeout_sec=0) with contextlib.suppress(TransformException): current_pose = tf_buffer.lookup_transform( "map", f"{self.platforms['vehicle'].namespace}/base_link", Time() @@ -106,15 +65,6 @@ def twist_callback(msg: TwistStamped) -> None: goal_pose.pose.position.z = current_pose.transform.translation.z publisher = test_node.create_publisher(PoseStamped, "/goal_pose", 10) - # wait_for_node_active( - # test_node, f"/{self.platforms['vehicle'].namespace}/bt_navigator", 10.0 - # ) - # wait_for_node_active( - # test_node, f"/{self.platforms['vehicle'].namespace}/planner_server", 10.0 - # ) - # wait_for_node_active( - # test_node, f"/{self.platforms['vehicle'].namespace}/controller_server", 10.0 - # ) wait_for_subscriber(publisher, timeout) publisher.publish(goal_pose) test_node.get_logger().info("Published goal pose for navigation.") @@ -142,7 +92,7 @@ def twist_callback(msg: TwistStamped) -> None: timed_out = True break - test_node.get_logger().info(f"Final TEST distance to goal: {distance}.") + test_node.get_logger().info(f"Final distance to goal: {distance}.") assert not timed_out, ( f"Timeout: distance {distance} > tolerance {navigation_distance_tolerance}" @@ -156,27 +106,14 @@ def twist_callback(msg: TwistStamped) -> None: ) -for i, vehicle in enumerate( - [ - "panther", - "lynx", - "panther", - "lynx", - "panther", - "lynx", - "panther", - "lynx", - "panther", - "lynx", - ] -): - for lidar in ["velodyne", "ouster", "velodyne", "ouster", "velodyne"]: +for vehicle in ["panther", "lynx"]: + for lidar in ["velodyne", "ouster"]: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) vehicle_platform.nav2_config.navigation = True test_class = type( - f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation{i}", + f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation", (_TestNavigationLidar,), {"platforms": {"vehicle": vehicle_platform, "lidar": lidar_platform}}, ) diff --git a/alliander_tests/src/alliander_tests/utils.py b/alliander_tests/src/alliander_tests/utils.py index c84cd7c96..37602f5b6 100644 --- a/alliander_tests/src/alliander_tests/utils.py +++ b/alliander_tests/src/alliander_tests/utils.py @@ -13,7 +13,6 @@ from builtin_interfaces.msg import Duration from control_msgs.action import FollowJointTrajectory from launch_testing_ros.wait_for_topics import WaitForTopics -from lifecycle_msgs.srv import GetState from rclpy.action import ActionClient from rclpy.action.client import ClientGoalHandle from rclpy.client import Client @@ -69,47 +68,6 @@ def wait_for_subscriber(pub: Publisher, timeout: int) -> None: time.sleep(0.1) -def wait_for_node_active(node: Node, lifecycle_node_name: str, timeout: float) -> None: - """Wait for a subscriber to be ready for a given publisher. - - Args: - node (Node): The publisher to wait for. - lifecycle_node_name (str): The name of the lifecycle node to wait for. - timeout (float): The maximum time to wait for a subscriber in seconds. - - Raises: - TimeoutError: If no subscriber is found within the timeout period. - - """ - client = node.create_client(GetState, f"{lifecycle_node_name}/get_state") - - if not client.wait_for_service(timeout_sec=timeout): - raise TimeoutError(f"Service {lifecycle_node_name} not available") - - start_time = time.time() - - while True: - request = GetState.Request() - future = client.call_async(request) - - rclpy.spin_until_future_complete(node, future, timeout_sec=1.0) - - if future.result() is not None: - state_id = future.result().current_state.id - state_label = future.result().current_state.label - - node.get_logger().info(f"{lifecycle_node_name} state: {state_label}") - - active_state = 3 - if state_id == active_state: - return - - if time.time() - start_time > timeout: - raise TimeoutError(f"{lifecycle_node_name} did not become ACTIVE") - - time.sleep(0.5) - - def get_joint_position(namespace: str, joint: str, timeout: int) -> float: """Get the position of a joint from the joint states topic. diff --git a/conftest.py b/conftest.py index 0c5d408ca..1c7ea7979 100644 --- a/conftest.py +++ b/conftest.py @@ -102,22 +102,7 @@ def control_class(request: SubRequest) -> Generator: if Configurations.mode != "all": skip_if_no_changes(services) pull_missing_images() - cprint("Check ros2 node list before starting containers.", "blue") - - active_nodes = subprocess.check_output( - ["ros2", "node", "list"], - stdin=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - while active_nodes != bytes(): - active_nodes = subprocess.check_output( - ["ros2", "node", "list"], - stdin=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - cprint(f"Waiting for the following nodes to exit: {active_nodes.decode("utf-8")}", "red") - time.sleep(1) - cprint("No more nodes active.", "blue") + wait_for_removal_nodes() process = start_containers(services) @@ -238,6 +223,24 @@ def pull_missing_images() -> None: subprocess.run(cmd, timeout=3600, shell=True, check=True) +def wait_for_removal_nodes() -> None: + """Wait for all active nodes to be stopped before moving on.""" + cprint("Checking ros2 node list before starting containers.", "blue") + + while active_nodes := subprocess.check_output( + ["ros2", "node", "list"], + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ): + cprint( + f"Waiting for the following nodes to exit: {active_nodes.decode('utf-8').strip()}", + "red", + ) + time.sleep(1) + + cprint("No more nodes active, moving on.", "blue") + + def start_containers(services: list) -> subprocess.Popen: """Start the Docker containers for all services. From c1c7d494925b3dc2579af060bf5397d92cd67e42 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 1 Apr 2026 11:19:34 +0200 Subject: [PATCH 038/119] Wait for compute_path_to_pose to become available Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../tests/test_nav2_navigation_lidar.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index bd5f5f92a..3c949737f 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -6,9 +6,12 @@ import sys import time +import pytest import rclpy from alliander_utilities.config_objects import Lidar, Vehicle, link from geometry_msgs.msg import PoseStamped, TransformStamped +from nav2_msgs.action import ComputePathToPose +from rclpy.action import ActionClient from rclpy.node import Node from rclpy.time import Time from tf2_ros import TransformException # ty: ignore[unresolved-import] @@ -57,6 +60,20 @@ def test_goal_pose_lidar( if time.time() - start_time > timeout: raise TimeoutError() + # 1.5) Wait for compute_path_to_pose to become ready + test_node.get_logger().info("Wait for compute_path_to_pose to become available.") + planner_client = ActionClient( + test_node, + ComputePathToPose, + f"/{self.platforms['vehicle'].namespace}/compute_path_to_pose", + ) + + start = time.time() + while not planner_client.wait_for_server(timeout_sec=1.0): + rclpy.spin_once(test_node, timeout_sec=0) + if time.time() - start > timeout: + pytest.fail("Planner action server never became available") + # 2) Publish goal pose 3 meter in front of current position: goal_pose = PoseStamped() goal_pose.header.frame_id = "map" @@ -86,7 +103,7 @@ def test_goal_pose_lidar( ) now = time.time() if now - last_log_time >= 1.0: - test_node.get_logger().info(f"Distance to goal: {distance:.2f}m") + test_node.get_logger().info(f"Distance to goal: {distance:.6f}m") last_log_time = now if time.time() - start_time > timeout: timed_out = True From 28dec623d59cd48615bbe503e07ac02108088257 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 1 Apr 2026 11:27:09 +0200 Subject: [PATCH 039/119] Now remove the wait again because maybe it blocked something? Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../tests/test_nav2_navigation_lidar.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 3c949737f..252daa37d 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -61,18 +61,18 @@ def test_goal_pose_lidar( raise TimeoutError() # 1.5) Wait for compute_path_to_pose to become ready - test_node.get_logger().info("Wait for compute_path_to_pose to become available.") - planner_client = ActionClient( - test_node, - ComputePathToPose, - f"/{self.platforms['vehicle'].namespace}/compute_path_to_pose", - ) - - start = time.time() - while not planner_client.wait_for_server(timeout_sec=1.0): - rclpy.spin_once(test_node, timeout_sec=0) - if time.time() - start > timeout: - pytest.fail("Planner action server never became available") + # test_node.get_logger().info("Wait for compute_path_to_pose to become available.") + # planner_client = ActionClient( + # test_node, + # ComputePathToPose, + # f"/{self.platforms['vehicle'].namespace}/compute_path_to_pose", + # ) + + # start = time.time() + # while not planner_client.wait_for_server(timeout_sec=1.0): + # rclpy.spin_once(test_node, timeout_sec=0) + # if time.time() - start > timeout: + # pytest.fail("Planner action server never became available") # 2) Publish goal pose 3 meter in front of current position: goal_pose = PoseStamped() From e5f3cd999807344056a9cee3fa9d26212bf45cc5 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 1 Apr 2026 11:38:36 +0200 Subject: [PATCH 040/119] Try increasing the timeout limit Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml index 30f32d01b..2eb0ae3f0 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml @@ -9,6 +9,8 @@ bt_navigator: default_nav_to_pose_bt_xml: substitute_me robot_base_frame: substitute_me odom_topic: substitute_me + default_server_timeout: 10000 + wait_for_service_timeout: 10000 error_code_names: - compute_path_error_code - follow_path_error_code From 6e9958fc0f34e881cea9480c02e7b9a0ae1e1839 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 1 Apr 2026 11:57:10 +0200 Subject: [PATCH 041/119] Add final increased timeout in BT yaml, and remove commented code Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../config/nav2/bt_navigator.yaml | 1 + .../tests/test_nav2_navigation_lidar.py | 17 ----------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml index 2eb0ae3f0..1b61d5703 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml @@ -10,6 +10,7 @@ bt_navigator: robot_base_frame: substitute_me odom_topic: substitute_me default_server_timeout: 10000 + default_cancel_timeout: 10000 wait_for_service_timeout: 10000 error_code_names: - compute_path_error_code diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 252daa37d..7f5578b4e 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -6,12 +6,9 @@ import sys import time -import pytest import rclpy from alliander_utilities.config_objects import Lidar, Vehicle, link from geometry_msgs.msg import PoseStamped, TransformStamped -from nav2_msgs.action import ComputePathToPose -from rclpy.action import ActionClient from rclpy.node import Node from rclpy.time import Time from tf2_ros import TransformException # ty: ignore[unresolved-import] @@ -60,20 +57,6 @@ def test_goal_pose_lidar( if time.time() - start_time > timeout: raise TimeoutError() - # 1.5) Wait for compute_path_to_pose to become ready - # test_node.get_logger().info("Wait for compute_path_to_pose to become available.") - # planner_client = ActionClient( - # test_node, - # ComputePathToPose, - # f"/{self.platforms['vehicle'].namespace}/compute_path_to_pose", - # ) - - # start = time.time() - # while not planner_client.wait_for_server(timeout_sec=1.0): - # rclpy.spin_once(test_node, timeout_sec=0) - # if time.time() - start > timeout: - # pytest.fail("Planner action server never became available") - # 2) Publish goal pose 3 meter in front of current position: goal_pose = PoseStamped() goal_pose.header.frame_id = "map" From bd494255814a0f7138e1ea31a0f0115e1c9ef013 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 1 Apr 2026 12:04:58 +0200 Subject: [PATCH 042/119] Revert the PR pytest settings Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 2 +- .../src/alliander_tests/tests/test_nav2_navigation_lidar.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bc33995da..1a68a0fe4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -205,4 +205,4 @@ jobs: - name: Install dependencies run: pip install pyyaml mashumaro - name: Run tests in alliander_tests container - run: python3 start.py --pytest-no-nvidia --mode all -k nav2_navigation_lidar + run: python3 start.py --pytest-no-nvidia diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 7f5578b4e..7fa028d1c 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -81,9 +81,9 @@ def test_goal_pose_lidar( current_pose = tf_buffer.lookup_transform( "map", f"{self.platforms['vehicle'].namespace}/base_link", Time() ) - distance = abs( - current_pose.transform.translation.x - goal_pose.pose.position.x - ) + distance = abs( + current_pose.transform.translation.x - goal_pose.pose.position.x + ) now = time.time() if now - last_log_time >= 1.0: test_node.get_logger().info(f"Distance to goal: {distance:.6f}m") From 15b20069f223f370e06346f23ad2dfe37aaad0f1 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 1 Apr 2026 13:50:00 +0200 Subject: [PATCH 043/119] Give each test a new ros domain id Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- conftest.py | 7 +++++-- start.py | 13 +++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/conftest.py b/conftest.py index 1c7ea7979..7d618dcce 100644 --- a/conftest.py +++ b/conftest.py @@ -3,7 +3,6 @@ # SPDX-License-Identifier: Apache-2.0 """Global pytest fixtures for ROS 2 integration testing.""" - import json import os import signal @@ -35,10 +34,12 @@ class Configurations: Attributes: mode (str): The mode of testing. changed_packages (set[str]): The set of packages that have changed. + ros_domain_id (int): ... """ mode: str changed_packages: set[str] + ros_domain_id: int = 0 def pytest_addoption(parser: Parser) -> None: @@ -96,6 +97,8 @@ def control_class(request: SubRequest) -> Generator: Yields: Generator: Starts and stops Docker containers for each module. """ + Configurations.ros_domain_id += 1 + os.environ["ROS_DOMAIN_ID"] = f"{Configurations.ros_domain_id}" print("") cprint(f"[{request.cls.__name__}]: started", "blue") services = create_compose_file(request) @@ -140,7 +143,7 @@ def create_compose_file(request: SubRequest) -> list: Returns: list: The list of services defined in the compose file. """ - compose = Compose() + compose = Compose(Configurations.ros_domain_id) if os.getenv("NO_NVIDIA", default="false").lower() == "true": compose.mode = "configuration-no-nvidia" else: diff --git a/start.py b/start.py index 5a5f32232..f45cca0d6 100755 --- a/start.py +++ b/start.py @@ -60,8 +60,12 @@ class Compose: host_cwd: str = os.path.abspath(os.getcwd()) home_dir: str = os.path.expanduser("~") - def __init__(self) -> None: - """Initialize.""" + def __init__(self, ros_domain_id: int = 0) -> None: + """Initialize. + + Args: + ros_domain_id (int): optionally provide ros domain id. + """ self.mode: MODE | None = None self.remove_nvidia = False self.predefined_configuration = PredefinedConfigurations() @@ -72,6 +76,8 @@ def __init__(self) -> None: self.joystick = False self.rviz_yaml = False + self.ros_domain_id = ros_domain_id + self.changed_packages = utils.get_changed_packages() @staticmethod @@ -296,9 +302,12 @@ def apply_env_settings(self, service: dict, service_type: SERVICE) -> None: service (dict): dictionary containing Docker container's YAML config. service_type (SERVICE): type of service being created. """ + if self.ros_domain_id != 0: + service["environment"] = [f"ROS_DOMAIN_ID={self.ros_domain_id}"] if service_type not in {"pytest", "pytest-no-nvidia"}: return + service["environment"] = ["ROS_DOMAIN_ID=0"] env_vars = service.get("environment", []) if self.predefined_configuration.sim_conf.load_ui: env_vars.append("GAZEBO_UI=true") From f463fbe0d65207f0afd224263b69e12f6479dbbb Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 1 Apr 2026 14:49:19 +0200 Subject: [PATCH 044/119] Add temporary diagnostics for cmd_vel that checks for nan values Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../alliander_diagnostics.Dockerfile | 2 +- .../src/alliander_diagnostics/CMakeLists.txt | 7 ++ .../launch/diagnostics.launch.py | 7 ++ .../src_py/cmd_vel_logger.py | 87 +++++++++++++++++++ pyproject.toml | 3 + uv.lock | 4 + 6 files changed, 109 insertions(+), 1 deletion(-) create mode 100755 alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py diff --git a/alliander_diagnostics/alliander_diagnostics.Dockerfile b/alliander_diagnostics/alliander_diagnostics.Dockerfile index 1de4bee7b..57b60ead3 100644 --- a/alliander_diagnostics/alliander_diagnostics.Dockerfile +++ b/alliander_diagnostics/alliander_diagnostics.Dockerfile @@ -16,7 +16,7 @@ RUN /$WORKDIR/colcon_build.sh # Install python dependencies: WORKDIR $WORKDIR COPY pyproject.toml /$WORKDIR/pyproject.toml -RUN uv sync \ +RUN uv sync --group alliander-diagnostics \ && echo "export PYTHONPATH=\"$(dirname $(dirname $(uv python find)))/lib/python3.12/site-packages:\$PYTHONPATH\"" >> /root/.bashrc \ && echo "export PATH=\"$(dirname $(dirname $(uv python find)))/bin:\$PATH\"" >> /root/.bashrc diff --git a/alliander_diagnostics/src/alliander_diagnostics/CMakeLists.txt b/alliander_diagnostics/src/alliander_diagnostics/CMakeLists.txt index 1b3b0e011..c60abc14d 100644 --- a/alliander_diagnostics/src/alliander_diagnostics/CMakeLists.txt +++ b/alliander_diagnostics/src/alliander_diagnostics/CMakeLists.txt @@ -7,12 +7,19 @@ project(alliander_diagnostics) # CMake dependencies: find_package(ament_cmake REQUIRED) +find_package(ament_cmake_python REQUIRED) # Other dependencies: find_package(rclcpp REQUIRED) find_package(diagnostic_msgs REQUIRED) find_package(sensor_msgs REQUIRED) +# Python executables: +install( + DIRECTORY src_py/ + DESTINATION lib/${PROJECT_NAME} +) + # C++ executables: include_directories(include) add_executable(${PROJECT_NAME}_node diff --git a/alliander_diagnostics/src/alliander_diagnostics/launch/diagnostics.launch.py b/alliander_diagnostics/src/alliander_diagnostics/launch/diagnostics.launch.py index f59b4a41b..007215ba7 100644 --- a/alliander_diagnostics/src/alliander_diagnostics/launch/diagnostics.launch.py +++ b/alliander_diagnostics/src/alliander_diagnostics/launch/diagnostics.launch.py @@ -72,10 +72,17 @@ def launch_setup(context: LaunchContext) -> list: parameters=[parameters], ) + cmd_vel_logger_node = Node( + package="alliander_diagnostics", + executable="cmd_vel_logger.py", + name="cmd_vel_logger", + ) + use_sim_time = use_sim_time_arg.bool_value(context) return [ SetParameter(name="use_sim_time", value=use_sim_time), + Register.on_start(cmd_vel_logger_node, context), Register.on_start(diagnostics_node, context), ] diff --git a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py new file mode 100755 index 000000000..ada4599a7 --- /dev/null +++ b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +import math + +import rclpy +from geometry_msgs.msg import TwistStamped +from rclpy.node import Node + +# Thresholds for abnormal behaviour: +MAX_LINEAR_X = 2.0 # m/s +MAX_LINEAR_Y = 0.1 # m/s — differential drive should not move sideways +MAX_ANGULAR_Z = 2.0 # rad/s + + +class CmdVelLogger(Node): + """Test.""" + def __init__(self) -> None: + """Test.""" + super().__init__("cmd_vel_logger") + self.sub_panther = self.create_subscription( + TwistStamped, "/panther/cmd_vel", self._callback_panther, 10 + ) + self.sub_lynx = self.create_subscription( + TwistStamped, "/lynx/cmd_vel", self._callback_lynx, 10 + ) + self.get_logger().info("cmd_vel_logger started, listening to /panther/cmd_vel and /lynx/cmd_vel") + + def _callback_panther(self, msg: TwistStamped) -> None: + sender = msg.header.frame_id or "" + self.get_logger().info(f"Received from frame_id: {sender}") + self._check_nan(msg) + self._check_limits(msg) + + def _callback_lynx(self, msg: TwistStamped) -> None: + sender = msg.header.frame_id or "" + self.get_logger().info(f"Received from frame_id: {sender}") + self._check_nan(msg) + self._check_limits(msg) + + def _check_nan(self, msg: TwistStamped) -> None: + msg = msg.twist + values = { + "linear.x": msg.linear.x, + "linear.y": msg.linear.y, + "linear.z": msg.linear.z, + "angular.x": msg.angular.x, + "angular.y": msg.angular.y, + "angular.z": msg.angular.z, + } + for field, value in values.items(): + if math.isnan(value) or math.isinf(value): + self.get_logger().error(f"cmd_vel contains invalid value in {field}: {value}") + + def _check_limits(self, msg: TwistStamped) -> None: + msg = msg.twist + if abs(msg.linear.x) > MAX_LINEAR_X: + self.get_logger().warning( + f"Linear X velocity {msg.linear.x:.2f} m/s exceeds limit of {MAX_LINEAR_X} m/s" + ) + if abs(msg.linear.y) > MAX_LINEAR_Y: + self.get_logger().warning( + f"Unexpected lateral velocity linear.y={msg.linear.y:.2f} m/s " + f"(differential drive should not move sideways)" + ) + if abs(msg.angular.z) > MAX_ANGULAR_Z: + self.get_logger().warning( + f"Angular Z velocity {msg.angular.z:.2f} rad/s exceeds limit of {MAX_ANGULAR_Z} rad/s" + ) + + +def main() -> None: + """Test.""" + rclpy.init() + node = CmdVelLogger() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 680dfea9e..42f501deb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ linting = [ "ruff>=0.14.13", "ty>=0.0.12", ] +alliander-diagnostics = [ + "numpy>=2.4.1", +] alliander-franka = [ "numpy>=2.4.1", ] diff --git a/uv.lock b/uv.lock index 1893cfb35..42280d665 100644 --- a/uv.lock +++ b/uv.lock @@ -89,6 +89,9 @@ dependencies = [ ] [package.dev-dependencies] +alliander-diagnostics = [ + { name = "numpy" }, +] alliander-franka = [ { name = "numpy" }, ] @@ -153,6 +156,7 @@ requires-dist = [ ] [package.metadata.requires-dev] +alliander-diagnostics = [{ name = "numpy", specifier = ">=2.4.1" }] alliander-franka = [{ name = "numpy", specifier = ">=2.4.1" }] alliander-gazebo = [ { name = "numpy", specifier = ">=2.4.1" }, From ddcca3229b9616c66aadd7c86830c9b44efee531 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 1 Apr 2026 14:54:24 +0200 Subject: [PATCH 045/119] Add check for who published the cmd_vel Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src_py/cmd_vel_logger.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py index ada4599a7..5275d38e0 100755 --- a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py +++ b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py @@ -28,16 +28,28 @@ def __init__(self) -> None: self.get_logger().info("cmd_vel_logger started, listening to /panther/cmd_vel and /lynx/cmd_vel") def _callback_panther(self, msg: TwistStamped) -> None: - sender = msg.header.frame_id or "" - self.get_logger().info(f"Received from frame_id: {sender}") self._check_nan(msg) self._check_limits(msg) + publishers = self.get_publishers_info_by_topic("/panther/cmd_vel") + for pub in publishers: + self.get_logger().info( + f"Publisher on /panther/cmd_vel: " + f"node={pub.node_name}, " + f"namespace={pub.node_namespace}, " + f"type={pub.topic_type}" + ) def _callback_lynx(self, msg: TwistStamped) -> None: - sender = msg.header.frame_id or "" - self.get_logger().info(f"Received from frame_id: {sender}") self._check_nan(msg) self._check_limits(msg) + publishers = self.get_publishers_info_by_topic("/lynx/cmd_vel") + for pub in publishers: + self.get_logger().info( + f"Publisher on /lynx/cmd_vel: " + f"node={pub.node_name}, " + f"namespace={pub.node_namespace}, " + f"type={pub.topic_type}" + ) def _check_nan(self, msg: TwistStamped) -> None: msg = msg.twist From 1f4ad12c3bd0cad7c3f838561df3ee6bf6ec90e7 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 1 Apr 2026 15:12:17 +0200 Subject: [PATCH 046/119] Move publisher check to NaN check Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src_py/cmd_vel_logger.py | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py index 5275d38e0..1308d00e7 100755 --- a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py +++ b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py @@ -3,19 +3,21 @@ # # SPDX-License-Identifier: Apache-2.0 import math +from os import name import rclpy from geometry_msgs.msg import TwistStamped from rclpy.node import Node # Thresholds for abnormal behaviour: -MAX_LINEAR_X = 2.0 # m/s -MAX_LINEAR_Y = 0.1 # m/s — differential drive should not move sideways -MAX_ANGULAR_Z = 2.0 # rad/s +MAX_LINEAR_X = 2.0 # m/s +MAX_LINEAR_Y = 0.1 # m/s — differential drive should not move sideways +MAX_ANGULAR_Z = 2.0 # rad/s class CmdVelLogger(Node): """Test.""" + def __init__(self) -> None: """Test.""" super().__init__("cmd_vel_logger") @@ -25,33 +27,19 @@ def __init__(self) -> None: self.sub_lynx = self.create_subscription( TwistStamped, "/lynx/cmd_vel", self._callback_lynx, 10 ) - self.get_logger().info("cmd_vel_logger started, listening to /panther/cmd_vel and /lynx/cmd_vel") + self.get_logger().info( + "cmd_vel_logger started, listening to /panther/cmd_vel and /lynx/cmd_vel" + ) def _callback_panther(self, msg: TwistStamped) -> None: - self._check_nan(msg) + self._check_nan(msg, "panther") self._check_limits(msg) - publishers = self.get_publishers_info_by_topic("/panther/cmd_vel") - for pub in publishers: - self.get_logger().info( - f"Publisher on /panther/cmd_vel: " - f"node={pub.node_name}, " - f"namespace={pub.node_namespace}, " - f"type={pub.topic_type}" - ) def _callback_lynx(self, msg: TwistStamped) -> None: - self._check_nan(msg) + self._check_nan(msg, "lynx") self._check_limits(msg) - publishers = self.get_publishers_info_by_topic("/lynx/cmd_vel") - for pub in publishers: - self.get_logger().info( - f"Publisher on /lynx/cmd_vel: " - f"node={pub.node_name}, " - f"namespace={pub.node_namespace}, " - f"type={pub.topic_type}" - ) - def _check_nan(self, msg: TwistStamped) -> None: + def _check_nan(self, msg: TwistStamped, namespace: str) -> None: msg = msg.twist values = { "linear.x": msg.linear.x, @@ -63,7 +51,17 @@ def _check_nan(self, msg: TwistStamped) -> None: } for field, value in values.items(): if math.isnan(value) or math.isinf(value): - self.get_logger().error(f"cmd_vel contains invalid value in {field}: {value}") + self.get_logger().error( + f"cmd_vel contains invalid value in {field}: {value}" + ) + publishers = self.get_publishers_info_by_topic(f"/{namespace}/cmd_vel") + for pub in publishers: + self.get_logger().info( + f"Publisher on /{namespace}/cmd_vel: " + f"node={pub.node_name}, " + f"namespace={pub.node_namespace}, " + f"type={pub.topic_type}" + ) def _check_limits(self, msg: TwistStamped) -> None: msg = msg.twist From 5c5e95d4135a17b7a9f2e300030bd1488b2be283 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 1 Apr 2026 15:29:51 +0200 Subject: [PATCH 047/119] Tests taking forever, only run tests for nav2 Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 2 +- .../src/alliander_diagnostics/src_py/cmd_vel_logger.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1a68a0fe4..ef2f980ff 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -205,4 +205,4 @@ jobs: - name: Install dependencies run: pip install pyyaml mashumaro - name: Run tests in alliander_tests container - run: python3 start.py --pytest-no-nvidia + run: python3 start.py --pytest-no-nvidia --mode all -k \"collision_monitor or nav2_navigation_gps or nav2_navigation_lidar\" diff --git a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py index 1308d00e7..0a17b2dda 100755 --- a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py +++ b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py @@ -3,7 +3,6 @@ # # SPDX-License-Identifier: Apache-2.0 import math -from os import name import rclpy from geometry_msgs.msg import TwistStamped From 8c738e27aa2e667b79b58c481a89e9b26700b73d Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 2 Apr 2026 12:26:08 +0200 Subject: [PATCH 048/119] Redirect cmd_vel such that nav2 will not hear it Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_husarion/launch/controllers.launch.py | 2 +- alliander_nav2/src/alliander_nav2/launch/nav2.launch.py | 8 ++++---- .../src/alliander_tests/tests/test_collision_monitor.py | 1 + .../src/alliander_tests/tests/test_nav2_navigation_gps.py | 1 + .../alliander_tests/tests/test_nav2_navigation_lidar.py | 1 + 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index 78c97ae4f..8a83f8856 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -69,7 +69,7 @@ def launch_setup(context: LaunchContext) -> list: "--controller-manager", "controller_manager", "--controller-ros-args", - "--remap drive_controller/cmd_vel:=cmd_vel", + "--remap drive_controller/cmd_vel:=cmd_vel_bluh", "--controller-ros-args", "--remap drive_controller/odom:=odometry/wheels", "--controller-ros-args", diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 801b94d91..b334b5420 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -186,7 +186,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 "base_frame_id": f"{namespace_vehicle}/base_footprint", "odom_frame_id": f"{namespace_vehicle}/odom", "cmd_vel_in_topic": f"/{namespace_vehicle}/cmd_vel_raw", - "cmd_vel_out_topic": f"/{namespace_vehicle}/cmd_vel", + "cmd_vel_out_topic": f"/{namespace_vehicle}/cmd_vel_test", "scan": { "topic": f"/{namespace_lidar}/scan", }, @@ -311,9 +311,9 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 ) pub_topic = ( - f"/{namespace_vehicle}/cmd_vel" + f"/{namespace_vehicle}/cmd_vel_test" if not nav2.collision_monitor - else f"/{namespace_vehicle}/cmd_vel_raw" + else f"/{namespace_vehicle}/cmd_vel_raw_test" ) register_lifecycle_nodes = [] @@ -322,7 +322,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 return [ SetParameter(name="use_sim_time", value=vehicle_config.simulation), - SetRemap(src="/cmd_vel", dst=pub_topic), + # SetRemap(src="/cmd_vel", dst=pub_topic), *[Register.on_start(node, context) for node in register_lifecycle_nodes], Register.on_log(lifecycle_manager, "Managed nodes are active", context), Register.on_log(nav2_manager, "Controller is ready.", context) diff --git a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py index 86b25b439..5aa0ed5d0 100644 --- a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py +++ b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py @@ -30,6 +30,7 @@ def test_collision_monitoring(self, test_node: Node, timeout: int) -> None: test_node (Node): The ROS 2 node to use for the test. timeout (int): The timeout in seconds. """ + timeout = 2 # TEST input_velocity = 0.0001 expected_output = input_velocity * 0.7 diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index 52f3b5b52..27b490442 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -39,6 +39,7 @@ def test_goal_pose_gps( Raises: TimeoutError: When a timeout occurs. """ + timeout = 2 # TEST # 1) Obtain current GPS location: current_nav_sat = NavSatFix() diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 7fa028d1c..93f2a97a3 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -42,6 +42,7 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ + timeout = 2 # TEST # 1) Obtain current pose in map frame: tf_buffer = Buffer() TransformListener(tf_buffer, test_node) From c18af8ab9fd6fcb948eb368785eac06fc39d821c Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 2 Apr 2026 13:04:29 +0200 Subject: [PATCH 049/119] Check /drive_controller/cmd_vel Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src_py/cmd_vel_logger.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py index 0a17b2dda..f9364ca22 100755 --- a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py +++ b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py @@ -26,6 +26,9 @@ def __init__(self) -> None: self.sub_lynx = self.create_subscription( TwistStamped, "/lynx/cmd_vel", self._callback_lynx, 10 ) + self.sub_drive_controller = self.create_subscription( + TwistStamped, "/drive_controller/cmd_vel", self._callback_cmd_vel, 10 + ) self.get_logger().info( "cmd_vel_logger started, listening to /panther/cmd_vel and /lynx/cmd_vel" ) @@ -38,6 +41,17 @@ def _callback_lynx(self, msg: TwistStamped) -> None: self._check_nan(msg, "lynx") self._check_limits(msg) + def _callback_cmd_vel(self, msg: TwistStamped) -> None: + self.get_logger().warn(f"Found data on /drive_controller/cmd_vel, with timestamp: {msg.header.stamp}") + publishers = self.get_publishers_info_by_topic("/drive_controller/cmd_vel") + for pub in publishers: + self.get_logger().info( + f"Publisher on /drive_controller/cmd_vel: " + f"node={pub.node_name}, " + f"namespace={pub.node_namespace}, " + f"type={pub.topic_type}" + ) + def _check_nan(self, msg: TwistStamped, namespace: str) -> None: msg = msg.twist values = { @@ -49,7 +63,7 @@ def _check_nan(self, msg: TwistStamped, namespace: str) -> None: "angular.z": msg.angular.z, } for field, value in values.items(): - if math.isnan(value) or math.isinf(value): + if math.isnan(value) or math.isinf(value) or not math.isfinite(value): self.get_logger().error( f"cmd_vel contains invalid value in {field}: {value}" ) From 0aa5d83188a6e9ccd5aa966b0618b0d3cc321e0f Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 2 Apr 2026 14:55:33 +0200 Subject: [PATCH 050/119] Change gazebo unpause from on_start to on_exit Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py index 8bafbb66a..cb627f639 100644 --- a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py +++ b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py @@ -157,7 +157,7 @@ def launch_setup(context: LaunchContext) -> list: context, ), Register.on_log(spawn_platforms, "All platforms spawned!", context), - Register.on_start(unpause_sim, context), + Register.on_exit(unpause_sim, context), ] From 6dab96bb08329e472c71515bb67f78bc91db7cd2 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 2 Apr 2026 15:14:54 +0200 Subject: [PATCH 051/119] Revert redirection cmd_vels Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_husarion/launch/controllers.launch.py | 2 +- alliander_nav2/src/alliander_nav2/launch/nav2.launch.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index 8a83f8856..78c97ae4f 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -69,7 +69,7 @@ def launch_setup(context: LaunchContext) -> list: "--controller-manager", "controller_manager", "--controller-ros-args", - "--remap drive_controller/cmd_vel:=cmd_vel_bluh", + "--remap drive_controller/cmd_vel:=cmd_vel", "--controller-ros-args", "--remap drive_controller/odom:=odometry/wheels", "--controller-ros-args", diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index b334b5420..801b94d91 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -186,7 +186,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 "base_frame_id": f"{namespace_vehicle}/base_footprint", "odom_frame_id": f"{namespace_vehicle}/odom", "cmd_vel_in_topic": f"/{namespace_vehicle}/cmd_vel_raw", - "cmd_vel_out_topic": f"/{namespace_vehicle}/cmd_vel_test", + "cmd_vel_out_topic": f"/{namespace_vehicle}/cmd_vel", "scan": { "topic": f"/{namespace_lidar}/scan", }, @@ -311,9 +311,9 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 ) pub_topic = ( - f"/{namespace_vehicle}/cmd_vel_test" + f"/{namespace_vehicle}/cmd_vel" if not nav2.collision_monitor - else f"/{namespace_vehicle}/cmd_vel_raw_test" + else f"/{namespace_vehicle}/cmd_vel_raw" ) register_lifecycle_nodes = [] @@ -322,7 +322,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 return [ SetParameter(name="use_sim_time", value=vehicle_config.simulation), - # SetRemap(src="/cmd_vel", dst=pub_topic), + SetRemap(src="/cmd_vel", dst=pub_topic), *[Register.on_start(node, context) for node in register_lifecycle_nodes], Register.on_log(lifecycle_manager, "Managed nodes are active", context), Register.on_log(nav2_manager, "Controller is ready.", context) From dd67e433b68ad5f44260f79a829f3dfc9b5f4dbf Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 2 Apr 2026 15:29:09 +0200 Subject: [PATCH 052/119] Remove decreased timeout for quick testing Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_tests/tests/test_collision_monitor.py | 1 - .../src/alliander_tests/tests/test_nav2_navigation_gps.py | 1 - .../src/alliander_tests/tests/test_nav2_navigation_lidar.py | 1 - 3 files changed, 3 deletions(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py index 5aa0ed5d0..86b25b439 100644 --- a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py +++ b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py @@ -30,7 +30,6 @@ def test_collision_monitoring(self, test_node: Node, timeout: int) -> None: test_node (Node): The ROS 2 node to use for the test. timeout (int): The timeout in seconds. """ - timeout = 2 # TEST input_velocity = 0.0001 expected_output = input_velocity * 0.7 diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index 27b490442..52f3b5b52 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -39,7 +39,6 @@ def test_goal_pose_gps( Raises: TimeoutError: When a timeout occurs. """ - timeout = 2 # TEST # 1) Obtain current GPS location: current_nav_sat = NavSatFix() diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 93f2a97a3..7fa028d1c 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -42,7 +42,6 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ - timeout = 2 # TEST # 1) Obtain current pose in map frame: tf_buffer = Buffer() TransformListener(tf_buffer, test_node) From 2d9029f08686e37358d667df8abe7294e2961dd1 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 2 Apr 2026 16:23:56 +0200 Subject: [PATCH 053/119] Try first unpausing sim, then spawn platforms, to see what happens Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_gazebo/launch/gazebo.launch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py index cb627f639..ddde6437f 100644 --- a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py +++ b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py @@ -156,8 +156,9 @@ def launch_setup(context: LaunchContext) -> list: "Creating GZ->ROS Bridge: [/clock (gz.msgs.Clock) -> /clock (rosgraph_msgs/msg/Clock)]", context, ), - Register.on_log(spawn_platforms, "All platforms spawned!", context), - Register.on_exit(unpause_sim, context), + Register.on_start(unpause_sim, context), + # Register.on_log(spawn_platforms, "All platforms spawned!", context), + Register.on_exit(spawn_platforms, context), ] From f883a0bc6cee931750a8c45a2c64d773dec806f4 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 2 Apr 2026 16:40:17 +0200 Subject: [PATCH 054/119] Try to fix linting Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_diagnostics/src_py/cmd_vel_logger.py | 4 +++- alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py | 1 - conftest.py | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py index f9364ca22..e8ca0a01f 100755 --- a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py +++ b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py @@ -42,7 +42,9 @@ def _callback_lynx(self, msg: TwistStamped) -> None: self._check_limits(msg) def _callback_cmd_vel(self, msg: TwistStamped) -> None: - self.get_logger().warn(f"Found data on /drive_controller/cmd_vel, with timestamp: {msg.header.stamp}") + self.get_logger().warn( + f"Found data on /drive_controller/cmd_vel, with timestamp: {msg.header.stamp}" + ) publishers = self.get_publishers_info_by_topic("/drive_controller/cmd_vel") for pub in publishers: self.get_logger().info( diff --git a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py index ddde6437f..e43b8d1b5 100644 --- a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py +++ b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py @@ -157,7 +157,6 @@ def launch_setup(context: LaunchContext) -> list: context, ), Register.on_start(unpause_sim, context), - # Register.on_log(spawn_platforms, "All platforms spawned!", context), Register.on_exit(spawn_platforms, context), ] diff --git a/conftest.py b/conftest.py index 7d618dcce..1a0983dcf 100644 --- a/conftest.py +++ b/conftest.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 """Global pytest fixtures for ROS 2 integration testing.""" + import json import os import signal From fd5a59366ff3cdd684cc82e9d34c3d526c5b21eb Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 2 Apr 2026 17:56:41 +0200 Subject: [PATCH 055/119] Add wait for clock. Does not wait yet, but at least we'll get logs about when /clock gets messages Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../alliander_gazebo/launch/gazebo.launch.py | 9 +++- .../alliander_gazebo/src_py/wait_for_clock.py | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100755 alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py diff --git a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py index e43b8d1b5..77751a7cf 100644 --- a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py +++ b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py @@ -149,6 +149,12 @@ def launch_setup(context: LaunchContext) -> list: shell=False, ) + wait_for_clock = Node( + package="alliander_gazebo", + executable="wait_for_clock.py", + output="screen", + ) + return [ Register.on_start(gazebo, context), Register.on_log( @@ -157,7 +163,8 @@ def launch_setup(context: LaunchContext) -> list: context, ), Register.on_start(unpause_sim, context), - Register.on_exit(spawn_platforms, context), + Register.on_exit(wait_for_clock, context), + Register.on_start(spawn_platforms, context), ] diff --git a/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py b/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py new file mode 100755 index 000000000..8e0b30467 --- /dev/null +++ b/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 + +import rclpy +from rclpy.node import Node +from rosgraph_msgs.msg import Clock + + +class WaitForClock(Node): + """Test.""" + def __init__(self): + """Test.""" + super().__init__("wait_for_clock") + self.received = False + self.create_subscription(Clock, "/clock", self.cb, 10) + + def cb(self, msg: Clock) -> None: # noqa: ARG002 + """Test. + + Args: + msg (Clock): test. + """ + self.get_logger().info("Received /clock, continuing...") + self.received = True + + +def main(args: list | None = None) -> None: + """Test. + + Args: + args (list | None): Command line arguments, defaults to None. + """ + rclpy.init(args=args) + node = WaitForClock() + + while rclpy.ok() and not node.received: + rclpy.spin_once(node, timeout_sec=0.1) + + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() From f62c9d22e4d434d7a356a80f96800b4e79ec5943 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Fri, 3 Apr 2026 07:22:36 +0200 Subject: [PATCH 056/119] Also print the time Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py b/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py index 8e0b30467..6d4f270ee 100755 --- a/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py +++ b/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py @@ -22,7 +22,7 @@ def cb(self, msg: Clock) -> None: # noqa: ARG002 Args: msg (Clock): test. """ - self.get_logger().info("Received /clock, continuing...") + self.get_logger().info(f"Received /clock: {msg.clock}, continuing...") self.received = True From 180cb254b3e0fcc22f635f6cb74fce859fa91a6c Mon Sep 17 00:00:00 2001 From: Rosalie Date: Fri, 3 Apr 2026 09:13:04 +0200 Subject: [PATCH 057/119] Add sleep to nav2 Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_nav2/launch/nav2.launch.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 801b94d91..41cb44799 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -9,7 +9,7 @@ from alliander_utilities.register import Register from alliander_utilities.ros_utils import get_file_path from launch import LaunchContext, LaunchDescription -from launch.actions import OpaqueFunction +from launch.actions import ExecuteProcess, OpaqueFunction from launch_ros.actions import LifecycleNode, Node, SetParameter, SetRemap platform_arg = LaunchArgument("platform_config", "") @@ -320,9 +320,18 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 for node_name in lifecycle_nodes_names: register_lifecycle_nodes.append(all_lifecycle_nodes[node_name]) + sleep = ExecuteProcess( + cmd=[ + "sleep", + "15", + ], + shell=False, + ) + return [ SetParameter(name="use_sim_time", value=vehicle_config.simulation), SetRemap(src="/cmd_vel", dst=pub_topic), + Register.on_exit(sleep, context), *[Register.on_start(node, context) for node in register_lifecycle_nodes], Register.on_log(lifecycle_manager, "Managed nodes are active", context), Register.on_log(nav2_manager, "Controller is ready.", context) From 915e4d76aa47e996990e35686b7de02436cd3c4d Mon Sep 17 00:00:00 2001 From: Rosalie Date: Fri, 3 Apr 2026 11:45:47 +0200 Subject: [PATCH 058/119] Try running without nav2 again Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_tests/tests/test_collision_monitor.py | 5 +++-- .../src/alliander_tests/tests/test_nav2_navigation_gps.py | 5 +++-- .../src/alliander_tests/tests/test_nav2_navigation_lidar.py | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py index 86b25b439..a19eceb90 100644 --- a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py +++ b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py @@ -30,6 +30,7 @@ def test_collision_monitoring(self, test_node: Node, timeout: int) -> None: test_node (Node): The ROS 2 node to use for the test. timeout (int): The timeout in seconds. """ + timeout = 2 # TEST input_velocity = 0.0001 expected_output = input_velocity * 0.7 @@ -78,8 +79,8 @@ def callback_function_cmd_vel(msg: TwistStamped) -> None: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) - vehicle_platform.nav2_config.navigation = True - vehicle_platform.nav2_config.collision_monitor = True + # vehicle_platform.nav2_config.navigation = True + # vehicle_platform.nav2_config.collision_monitor = True test_class = type( f"Test{vehicle.capitalize()}{lidar.capitalize()}CollisionMonitoring", (_TestCollisionMonitoring,), diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index 52f3b5b52..a20a52342 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -39,6 +39,7 @@ def test_goal_pose_gps( Raises: TimeoutError: When a timeout occurs. """ + timeout = 2 # TEST # 1) Obtain current GPS location: current_nav_sat = NavSatFix() @@ -108,8 +109,8 @@ def callback(msg: NavSatFix) -> None: gps_platform = GPS(gps, (0, 0, 0.2)) link(vehicle_platform, lidar_platform) link(vehicle_platform, gps_platform) - vehicle_platform.nav2_config.navigation = True - vehicle_platform.nav2_config.gps = True + # vehicle_platform.nav2_config.navigation = True + # vehicle_platform.nav2_config.gps = True test_class = type( f"Test{vehicle.capitalize()}{lidar.capitalize()}{gps.capitalize()}Navigation", (_TestNavigationGPS,), diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 7fa028d1c..137ab9ceb 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -42,6 +42,7 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ + timeout = 2 # TEST # 1) Obtain current pose in map frame: tf_buffer = Buffer() TransformListener(tf_buffer, test_node) @@ -111,7 +112,7 @@ def test_goal_pose_lidar( vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) - vehicle_platform.nav2_config.navigation = True + # vehicle_platform.nav2_config.navigation = True test_class = type( f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation", (_TestNavigationLidar,), From 5b73d353281e35091e1966fdcd76af9d2965e6f4 Mon Sep 17 00:00:00 2001 From: Jelmer de Wolde Date: Fri, 3 Apr 2026 12:43:53 +0000 Subject: [PATCH 059/119] Test Nav2 with sleeps before every node. Signed-off-by: Jelmer de Wolde Signed-off-by: Peter Geurts --- .../src/alliander_nav2/launch/nav2.launch.py | 37 ++++++++++++++----- .../tests/test_collision_monitor.py | 5 +-- .../tests/test_nav2_navigation_gps.py | 5 +-- .../tests/test_nav2_navigation_lidar.py | 3 +- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 41cb44799..948eada51 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -320,26 +320,45 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 for node_name in lifecycle_nodes_names: register_lifecycle_nodes.append(all_lifecycle_nodes[node_name]) - sleep = ExecuteProcess( - cmd=[ - "sleep", - "15", - ], - shell=False, - ) + registered_nodes_with_sleeps = [] + for node in register_lifecycle_nodes: + registered_nodes_with_sleeps.append(registerd_sleep(context)) + registered_nodes_with_sleeps.append(Register.on_start(node, context)) return [ SetParameter(name="use_sim_time", value=vehicle_config.simulation), SetRemap(src="/cmd_vel", dst=pub_topic), - Register.on_exit(sleep, context), - *[Register.on_start(node, context) for node in register_lifecycle_nodes], + *registered_nodes_with_sleeps, + registerd_sleep(context), Register.on_log(lifecycle_manager, "Managed nodes are active", context), + registerd_sleep(context), Register.on_log(nav2_manager, "Controller is ready.", context) if nav2.navigation else SKIP, ] +def registerd_sleep(context: LaunchContext) -> LaunchDescription: + """Create a sleep. + + Args: + context (LaunchContext): The launch context. + + Returns: + LaunchDescription: The launch description containing the sleep action. + """ + return Register.on_exit( + ExecuteProcess( + cmd=[ + "sleep", + "10", + ], + shell=False, + ), + context, + ) + + def generate_launch_description() -> LaunchDescription: """Generate the launch description for the navigation stack. diff --git a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py index a19eceb90..86b25b439 100644 --- a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py +++ b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py @@ -30,7 +30,6 @@ def test_collision_monitoring(self, test_node: Node, timeout: int) -> None: test_node (Node): The ROS 2 node to use for the test. timeout (int): The timeout in seconds. """ - timeout = 2 # TEST input_velocity = 0.0001 expected_output = input_velocity * 0.7 @@ -79,8 +78,8 @@ def callback_function_cmd_vel(msg: TwistStamped) -> None: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) - # vehicle_platform.nav2_config.navigation = True - # vehicle_platform.nav2_config.collision_monitor = True + vehicle_platform.nav2_config.navigation = True + vehicle_platform.nav2_config.collision_monitor = True test_class = type( f"Test{vehicle.capitalize()}{lidar.capitalize()}CollisionMonitoring", (_TestCollisionMonitoring,), diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index a20a52342..52f3b5b52 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -39,7 +39,6 @@ def test_goal_pose_gps( Raises: TimeoutError: When a timeout occurs. """ - timeout = 2 # TEST # 1) Obtain current GPS location: current_nav_sat = NavSatFix() @@ -109,8 +108,8 @@ def callback(msg: NavSatFix) -> None: gps_platform = GPS(gps, (0, 0, 0.2)) link(vehicle_platform, lidar_platform) link(vehicle_platform, gps_platform) - # vehicle_platform.nav2_config.navigation = True - # vehicle_platform.nav2_config.gps = True + vehicle_platform.nav2_config.navigation = True + vehicle_platform.nav2_config.gps = True test_class = type( f"Test{vehicle.capitalize()}{lidar.capitalize()}{gps.capitalize()}Navigation", (_TestNavigationGPS,), diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 137ab9ceb..7fa028d1c 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -42,7 +42,6 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ - timeout = 2 # TEST # 1) Obtain current pose in map frame: tf_buffer = Buffer() TransformListener(tf_buffer, test_node) @@ -112,7 +111,7 @@ def test_goal_pose_lidar( vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) - # vehicle_platform.nav2_config.navigation = True + vehicle_platform.nav2_config.navigation = True test_class = type( f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation", (_TestNavigationLidar,), From 1aafc77df5b7d108cd46c350682fd88fda6e38ef Mon Sep 17 00:00:00 2001 From: Jelmer de Wolde Date: Fri, 3 Apr 2026 12:53:22 +0000 Subject: [PATCH 060/119] Increase maximum launch timeout and decrease sleep time per node. Signed-off-by: Jelmer de Wolde Signed-off-by: Peter Geurts --- alliander_nav2/src/alliander_nav2/launch/nav2.launch.py | 2 +- conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 948eada51..7399c0b4a 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -351,7 +351,7 @@ def registerd_sleep(context: LaunchContext) -> LaunchDescription: ExecuteProcess( cmd=[ "sleep", - "10", + "5", ], shell=False, ), diff --git a/conftest.py b/conftest.py index 1a0983dcf..8b8576c1d 100644 --- a/conftest.py +++ b/conftest.py @@ -24,7 +24,7 @@ from predefined_configurations import PredefinedConfigurations from start import Compose -LAUNCH_TIMEOUT = 90 # seconds +LAUNCH_TIMEOUT = 300 # seconds COMPOSE_FILE = "/alliander_robotics/compose_pytest.yml" HOST_COMPOSE_FILE = "/alliander_robotics/compose.yml" From 65d1e6e7304ee44ea84dd173a16837626df128e1 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 7 Apr 2026 09:55:37 +0200 Subject: [PATCH 061/119] Test 50 times per navigation test file, and copy the tests to new file and remove nav2 to simultaneously test without nav2. Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 2 +- .../alliander_gazebo/src_py/wait_for_clock.py | 1 + .../src/alliander_nav2/launch/nav2.launch.py | 2 +- .../tests/test_nav2_absent_navigation_gps.py | 136 ++++++++++++++++++ .../test_nav2_absent_navigation_lidar.py | 133 +++++++++++++++++ .../tests/test_nav2_navigation_gps.py | 19 ++- .../tests/test_nav2_navigation_lidar.py | 19 ++- 7 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py create mode 100644 alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ef2f980ff..55c9e25ac 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -205,4 +205,4 @@ jobs: - name: Install dependencies run: pip install pyyaml mashumaro - name: Run tests in alliander_tests container - run: python3 start.py --pytest-no-nvidia --mode all -k \"collision_monitor or nav2_navigation_gps or nav2_navigation_lidar\" + run: python3 start.py --pytest-no-nvidia --mode all -k \"nav2_absent_navigation_gps or nav2_absent_navigation_lidar or nav2_navigation_gps or nav2_navigation_lidar\" diff --git a/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py b/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py index 6d4f270ee..3a1c58a7d 100755 --- a/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py +++ b/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py @@ -10,6 +10,7 @@ class WaitForClock(Node): """Test.""" + def __init__(self): """Test.""" super().__init__("wait_for_clock") diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 7399c0b4a..500410922 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -15,7 +15,7 @@ platform_arg = LaunchArgument("platform_config", "") -def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 +def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 """The launch setup. Args: diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py new file mode 100644 index 000000000..d8eb3039e --- /dev/null +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 + +import copy +import sys +import time + +import rclpy +from alliander_utilities.config_objects import GPS, Lidar, Vehicle, link +from geographic_msgs.msg import GeoPath, GeoPoseStamped +from rclpy.node import Node +from sensor_msgs.msg import NavSatFix + +from ..utils import call_trigger_service, wait_for_subscriber + + +class _TestNavigationGPS: + """Base class for navigation GPS tests. + + Attributes: + platforms (dict): A dictionary of the platforms to launch. + world (str): The world to launch. + """ + + platforms: dict + world: str = "map_5.940906_51.966960" + + def test_goal_pose_gps( + self, test_node: Node, navigation_degree_tolerance: float, timeout: int + ) -> None: + """Test that navigation to a goal pose is successful. + + Args: + test_node (Node): The ROS 2 node to use for the test. + navigation_degree_tolerance (float): The tolerance for navigation. + timeout (int): The timeout in seconds. + + Raises: + TimeoutError: When a timeout occurs. + """ + timeout = 2 # TEST + # 1) Obtain current GPS location: + current_nav_sat = NavSatFix() + + def callback(msg: NavSatFix) -> None: + current_nav_sat.latitude = msg.latitude + current_nav_sat.longitude = msg.longitude + + test_node.create_subscription( + NavSatFix, f"/{self.platforms['gps'].namespace}/gps/fix", callback, 10 + ) + + start_time = time.time() + while current_nav_sat == NavSatFix(): + rclpy.spin_once(test_node, timeout_sec=0) + if time.time() - start_time > timeout: + raise TimeoutError("Timeout while waiting for current GPS location.") + + # 2) Publish goal GPS location 1e-5 degrees north of current location: + goal_nav_sat = copy.deepcopy(current_nav_sat) + goal_nav_sat.latitude += 1e-5 + + publisher = test_node.create_publisher(GeoPath, "/gps_waypoints", 10) + wait_for_subscriber(publisher, timeout) + goal_msg = GeoPath() + goal_pose = GeoPoseStamped() + goal_pose.pose.position.latitude = goal_nav_sat.latitude + goal_pose.pose.position.longitude = goal_nav_sat.longitude + goal_msg.poses.append(goal_pose) + publisher.publish(goal_msg) + + # 3) Wait until goal is reached within tolerance: + start_time = time.time() + distance: float = sys.float_info.max + timed_out = False + last_log_time = 0.0 + + while distance > navigation_degree_tolerance: + rclpy.spin_once(test_node, timeout_sec=0) + distance = abs(current_nav_sat.latitude - goal_nav_sat.latitude) + now = time.time() + if now - last_log_time >= 1.0: + test_node.get_logger().info(f"Distance to goal: {distance}") + last_log_time = now + if time.time() - start_time > timeout: + timed_out = True + break + + test_node.get_logger().info(f"Final distance to goal: {distance}.") + + assert not timed_out, ( + f"Timeout: distance {distance} > tolerance {navigation_degree_tolerance}" + ) + + # 4) Stop navigation, since the goal can be reached before the navigation is finished due to tolerance: + assert call_trigger_service( + test_node, + f"/{self.platforms['vehicle'].namespace}/nav2_manager/stop", + timeout, + ) + + +for i, vehicle in enumerate( + [ + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + ] +): + for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): + for gps in ["gps"]: + vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) + lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) + gps_platform = GPS(gps, (0, 0, 0.2)) + link(vehicle_platform, lidar_platform) + link(vehicle_platform, gps_platform) + test_class = type( + f"Test{vehicle.capitalize()}{lidar.capitalize()}{gps.capitalize()}Navigation{i}.{j}", + (_TestNavigationGPS,), + { + "platforms": { + "vehicle": vehicle_platform, + "lidar": lidar_platform, + "gps": gps_platform, + } + }, + ) + globals()[test_class.__name__] = test_class diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py new file mode 100644 index 000000000..731caf007 --- /dev/null +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py @@ -0,0 +1,133 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 + +import contextlib +import sys +import time + +import rclpy +from alliander_utilities.config_objects import Lidar, Vehicle, link +from geometry_msgs.msg import PoseStamped, TransformStamped +from rclpy.node import Node +from rclpy.time import Time +from tf2_ros import TransformException # ty: ignore[unresolved-import] +from tf2_ros.buffer import Buffer +from tf2_ros.transform_listener import TransformListener + +from ..utils import call_trigger_service, wait_for_subscriber + + +class _TestNavigationLidar: + """Base class for lidar navigation tests. + + Attributes: + platforms (dict): A dictionary of the platforms to launch. + world (str): The world to launch. + """ + + platforms: dict + world: str = "test_navigation.sdf" + + def test_goal_pose_lidar( + self, test_node: Node, navigation_distance_tolerance: float, timeout: int + ) -> None: + """Test that navigation to a goal pose is successful. + + Args: + test_node (Node): The ROS 2 node to use for the test. + navigation_distance_tolerance (float): The tolerance for navigation. + timeout (int): The timeout in seconds. + + Raises: + TimeoutError: When a timeout occurs. + """ + timeout = 2 # TEST + # 1) Obtain current pose in map frame: + tf_buffer = Buffer() + TransformListener(tf_buffer, test_node) + current_pose = TransformStamped() + + start_time = time.time() + while current_pose == TransformStamped(): + rclpy.spin_once(test_node, timeout_sec=0) + with contextlib.suppress(TransformException): + current_pose = tf_buffer.lookup_transform( + "map", f"{self.platforms['vehicle'].namespace}/base_link", Time() + ) + if time.time() - start_time > timeout: + raise TimeoutError() + + # 2) Publish goal pose 3 meter in front of current position: + goal_pose = PoseStamped() + goal_pose.header.frame_id = "map" + goal_pose.pose.position.x = current_pose.transform.translation.x + 3 + goal_pose.pose.position.y = current_pose.transform.translation.y + goal_pose.pose.position.z = current_pose.transform.translation.z + + publisher = test_node.create_publisher(PoseStamped, "/goal_pose", 10) + wait_for_subscriber(publisher, timeout) + publisher.publish(goal_pose) + test_node.get_logger().info("Published goal pose for navigation.") + + # 3) Wait until goal is reached within tolerance: + start_time = time.time() + distance: float = sys.float_info.max + timed_out = False + last_log_time = 0.0 + + while distance > navigation_distance_tolerance: + rclpy.spin_once(test_node, timeout_sec=0) + with contextlib.suppress(TransformException): + current_pose = tf_buffer.lookup_transform( + "map", f"{self.platforms['vehicle'].namespace}/base_link", Time() + ) + distance = abs( + current_pose.transform.translation.x - goal_pose.pose.position.x + ) + now = time.time() + if now - last_log_time >= 1.0: + test_node.get_logger().info(f"Distance to goal: {distance:.6f}m") + last_log_time = now + if time.time() - start_time > timeout: + timed_out = True + break + + test_node.get_logger().info(f"Final distance to goal: {distance}.") + + assert not timed_out, ( + f"Timeout: distance {distance} > tolerance {navigation_distance_tolerance}" + ) + + # 4) Stop navigation, since the goal can be reached before the navigation is finished due to tolerance: + assert call_trigger_service( + test_node, + f"/{self.platforms['vehicle'].namespace}/nav2_manager/stop", + timeout, + ) + + +for i, vehicle in enumerate( + [ + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + ] +): + for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): + vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) + lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) + link(vehicle_platform, lidar_platform) + test_class = type( + f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation{i}.{j}", + (_TestNavigationLidar,), + {"platforms": {"vehicle": vehicle_platform, "lidar": lidar_platform}}, + ) + globals()[test_class.__name__] = test_class diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index 52f3b5b52..6cb1d93b3 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -100,8 +100,21 @@ def callback(msg: NavSatFix) -> None: ) -for vehicle in ["panther", "lynx"]: - for lidar in ["velodyne", "ouster"]: +for i, vehicle in enumerate( + [ + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + ] +): + for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): for gps in ["gps"]: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) @@ -111,7 +124,7 @@ def callback(msg: NavSatFix) -> None: vehicle_platform.nav2_config.navigation = True vehicle_platform.nav2_config.gps = True test_class = type( - f"Test{vehicle.capitalize()}{lidar.capitalize()}{gps.capitalize()}Navigation", + f"Test{vehicle.capitalize()}{lidar.capitalize()}{gps.capitalize()}Navigation{i}.{j}", (_TestNavigationGPS,), { "platforms": { diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 7fa028d1c..0b5b72e4f 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -106,14 +106,27 @@ def test_goal_pose_lidar( ) -for vehicle in ["panther", "lynx"]: - for lidar in ["velodyne", "ouster"]: +for i, vehicle in enumerate( + [ + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + "panther", + "lynx", + ] +): + for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) vehicle_platform.nav2_config.navigation = True test_class = type( - f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation", + f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation{i}.{j}", (_TestNavigationLidar,), {"platforms": {"vehicle": vehicle_platform, "lidar": lidar_platform}}, ) From f2a98cb62cd54552d06cf8d20a56cde2c31a546e Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 7 Apr 2026 10:09:44 +0200 Subject: [PATCH 062/119] Add better naming for absent navigation tests for better distinction Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../alliander_tests/tests/test_nav2_absent_navigation_gps.py | 2 +- .../alliander_tests/tests/test_nav2_absent_navigation_lidar.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py index d8eb3039e..bc383a36f 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py @@ -123,7 +123,7 @@ def callback(msg: NavSatFix) -> None: link(vehicle_platform, lidar_platform) link(vehicle_platform, gps_platform) test_class = type( - f"Test{vehicle.capitalize()}{lidar.capitalize()}{gps.capitalize()}Navigation{i}.{j}", + f"Test{vehicle.capitalize()}{lidar.capitalize()}{gps.capitalize()}AbsentNavigation{i}.{j}", (_TestNavigationGPS,), { "platforms": { diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py index 731caf007..9b0fff735 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py @@ -126,7 +126,7 @@ def test_goal_pose_lidar( lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) test_class = type( - f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation{i}.{j}", + f"Test{vehicle.capitalize()}{lidar.capitalize()}AbsentNavigation{i}.{j}", (_TestNavigationLidar,), {"platforms": {"vehicle": vehicle_platform, "lidar": lidar_platform}}, ) From dd8796978cb511fe5b9f5ff0dac57a2e83a79e21 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 7 Apr 2026 12:28:18 +0200 Subject: [PATCH 063/119] Only run non-nav2 tests and only run 25 per test (due to github log limit) Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 2 +- .../tests/test_nav2_absent_navigation_gps.py | 10 +++++----- .../tests/test_nav2_absent_navigation_lidar.py | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 55c9e25ac..4cd307ef2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -205,4 +205,4 @@ jobs: - name: Install dependencies run: pip install pyyaml mashumaro - name: Run tests in alliander_tests container - run: python3 start.py --pytest-no-nvidia --mode all -k \"nav2_absent_navigation_gps or nav2_absent_navigation_lidar or nav2_navigation_gps or nav2_navigation_lidar\" + run: python3 start.py --pytest-no-nvidia --mode all -k \"nav2_absent_navigation_gps or nav2_absent_navigation_lidar\" diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py index bc383a36f..9457ee728 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py @@ -108,11 +108,11 @@ def callback(msg: NavSatFix) -> None: "panther", "lynx", "panther", - "lynx", - "panther", - "lynx", - "panther", - "lynx", + # "lynx", + # "panther", + # "lynx", + # "panther", + # "lynx", ] ): for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py index 9b0fff735..eeb4e7058 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py @@ -114,11 +114,11 @@ def test_goal_pose_lidar( "panther", "lynx", "panther", - "lynx", - "panther", - "lynx", - "panther", - "lynx", + # "lynx", + # "panther", + # "lynx", + # "panther", + # "lynx", ] ): for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): From 8059e5919dbcd40dab485eaf5cf3042b9ee802ad Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 7 Apr 2026 13:19:51 +0200 Subject: [PATCH 064/119] Remove asserts from tests for less spamming in logs, and add sleeps to gazebo launch Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../alliander_gazebo/launch/gazebo.launch.py | 25 +++++++++++++++++++ .../tests/test_nav2_absent_navigation_gps.py | 19 ++------------ .../test_nav2_absent_navigation_lidar.py | 17 ++----------- 3 files changed, 29 insertions(+), 32 deletions(-) diff --git a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py index 77751a7cf..3100ad4ff 100644 --- a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py +++ b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py @@ -157,17 +157,42 @@ def launch_setup(context: LaunchContext) -> list: return [ Register.on_start(gazebo, context), + registerd_sleep(context), Register.on_log( bridge, "Creating GZ->ROS Bridge: [/clock (gz.msgs.Clock) -> /clock (rosgraph_msgs/msg/Clock)]", context, ), + registerd_sleep(context), Register.on_start(unpause_sim, context), + registerd_sleep(context), Register.on_exit(wait_for_clock, context), + registerd_sleep(context), Register.on_start(spawn_platforms, context), ] +def registerd_sleep(context: LaunchContext) -> LaunchDescription: + """Create a sleep. + + Args: + context (LaunchContext): The launch context. + + Returns: + LaunchDescription: The launch description containing the sleep action. + """ + return Register.on_exit( + ExecuteProcess( + cmd=[ + "sleep", + "5", + ], + shell=False, + ), + context, + ) + + def generate_launch_description() -> LaunchDescription: """Generate the launch description for the Gazebo simulation with platforms. diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py index 9457ee728..9c0095bfb 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py @@ -12,7 +12,7 @@ from rclpy.node import Node from sensor_msgs.msg import NavSatFix -from ..utils import call_trigger_service, wait_for_subscriber +from ..utils import wait_for_subscriber class _TestNavigationGPS: @@ -39,7 +39,7 @@ def test_goal_pose_gps( Raises: TimeoutError: When a timeout occurs. """ - timeout = 2 # TEST + timeout = 1 # TEST # 1) Obtain current GPS location: current_nav_sat = NavSatFix() @@ -73,7 +73,6 @@ def callback(msg: NavSatFix) -> None: # 3) Wait until goal is reached within tolerance: start_time = time.time() distance: float = sys.float_info.max - timed_out = False last_log_time = 0.0 while distance > navigation_degree_tolerance: @@ -84,22 +83,8 @@ def callback(msg: NavSatFix) -> None: test_node.get_logger().info(f"Distance to goal: {distance}") last_log_time = now if time.time() - start_time > timeout: - timed_out = True break - test_node.get_logger().info(f"Final distance to goal: {distance}.") - - assert not timed_out, ( - f"Timeout: distance {distance} > tolerance {navigation_degree_tolerance}" - ) - - # 4) Stop navigation, since the goal can be reached before the navigation is finished due to tolerance: - assert call_trigger_service( - test_node, - f"/{self.platforms['vehicle'].namespace}/nav2_manager/stop", - timeout, - ) - for i, vehicle in enumerate( [ diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py index eeb4e7058..8cd12dfce 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py @@ -15,7 +15,7 @@ from tf2_ros.buffer import Buffer from tf2_ros.transform_listener import TransformListener -from ..utils import call_trigger_service, wait_for_subscriber +from ..utils import wait_for_subscriber class _TestNavigationLidar: @@ -42,7 +42,7 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ - timeout = 2 # TEST + timeout = 1 # TEST # 1) Obtain current pose in map frame: tf_buffer = Buffer() TransformListener(tf_buffer, test_node) @@ -73,7 +73,6 @@ def test_goal_pose_lidar( # 3) Wait until goal is reached within tolerance: start_time = time.time() distance: float = sys.float_info.max - timed_out = False last_log_time = 0.0 while distance > navigation_distance_tolerance: @@ -90,22 +89,10 @@ def test_goal_pose_lidar( test_node.get_logger().info(f"Distance to goal: {distance:.6f}m") last_log_time = now if time.time() - start_time > timeout: - timed_out = True break test_node.get_logger().info(f"Final distance to goal: {distance}.") - assert not timed_out, ( - f"Timeout: distance {distance} > tolerance {navigation_distance_tolerance}" - ) - - # 4) Stop navigation, since the goal can be reached before the navigation is finished due to tolerance: - assert call_trigger_service( - test_node, - f"/{self.platforms['vehicle'].namespace}/nav2_manager/stop", - timeout, - ) - for i, vehicle in enumerate( [ From 5628b5f44a46864a18d12df1785ddbc7ce8319ab Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 7 Apr 2026 14:15:26 +0200 Subject: [PATCH 065/119] Increase cmd_vel_timeout of the husarion vehicles Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../alliander_description/husarion/config/lynx_controllers.yaml | 2 +- .../husarion/config/panther_controllers.yaml | 2 +- .../alliander_tests/tests/test_nav2_absent_navigation_gps.py | 1 - .../alliander_tests/tests/test_nav2_absent_navigation_lidar.py | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/alliander_core/src/alliander_description/husarion/config/lynx_controllers.yaml b/alliander_core/src/alliander_description/husarion/config/lynx_controllers.yaml index 530883b63..f263d223c 100644 --- a/alliander_core/src/alliander_description/husarion/config/lynx_controllers.yaml +++ b/alliander_core/src/alliander_description/husarion/config/lynx_controllers.yaml @@ -64,7 +64,7 @@ enable_odom_tf: true - cmd_vel_timeout: 0.2 + cmd_vel_timeout: 10.0 publish_limited_velocity: false # Velocity and acceleration limits diff --git a/alliander_core/src/alliander_description/husarion/config/panther_controllers.yaml b/alliander_core/src/alliander_description/husarion/config/panther_controllers.yaml index 9ba477466..fbdd96ed2 100644 --- a/alliander_core/src/alliander_description/husarion/config/panther_controllers.yaml +++ b/alliander_core/src/alliander_description/husarion/config/panther_controllers.yaml @@ -65,7 +65,7 @@ enable_odom_tf: true - cmd_vel_timeout: 0.5 + cmd_vel_timeout: 10.0 #publish_limited_velocity: true use_stamped_vel: false diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py index 9c0095bfb..dd837c662 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py @@ -62,7 +62,6 @@ def callback(msg: NavSatFix) -> None: goal_nav_sat.latitude += 1e-5 publisher = test_node.create_publisher(GeoPath, "/gps_waypoints", 10) - wait_for_subscriber(publisher, timeout) goal_msg = GeoPath() goal_pose = GeoPoseStamped() goal_pose.pose.position.latitude = goal_nav_sat.latitude diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py index 8cd12dfce..8c6919ed7 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py @@ -66,7 +66,6 @@ def test_goal_pose_lidar( goal_pose.pose.position.z = current_pose.transform.translation.z publisher = test_node.create_publisher(PoseStamped, "/goal_pose", 10) - wait_for_subscriber(publisher, timeout) publisher.publish(goal_pose) test_node.get_logger().info("Published goal pose for navigation.") From b65fc5681e40ff682b9ddd5bfd96b0b5fb9e63dd Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 7 Apr 2026 15:03:51 +0200 Subject: [PATCH 066/119] Fix some linting, and run regular nav2 tests instead of absent nav2 tests Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 2 +- .../src/alliander_diagnostics/src_py/cmd_vel_logger.py | 3 +++ .../tests/test_nav2_absent_navigation_gps.py | 7 +------ .../tests/test_nav2_absent_navigation_lidar.py | 7 +------ .../alliander_tests/tests/test_nav2_navigation_gps.py | 10 +++++----- .../tests/test_nav2_navigation_lidar.py | 10 +++++----- 6 files changed, 16 insertions(+), 23 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4cd307ef2..d88c447e1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -205,4 +205,4 @@ jobs: - name: Install dependencies run: pip install pyyaml mashumaro - name: Run tests in alliander_tests container - run: python3 start.py --pytest-no-nvidia --mode all -k \"nav2_absent_navigation_gps or nav2_absent_navigation_lidar\" + run: python3 start.py --pytest-no-nvidia --mode all -k \"nav2_navigation_gps or nav2_navigation_lidar\" diff --git a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py index e8ca0a01f..b126b257c 100755 --- a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py +++ b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py @@ -29,6 +29,9 @@ def __init__(self) -> None: self.sub_drive_controller = self.create_subscription( TwistStamped, "/drive_controller/cmd_vel", self._callback_cmd_vel, 10 ) + self.sub_cmd_vel = self.create_subscription( + TwistStamped, "/cmd_vel", self._callback_cmd_vel, 10 + ) self.get_logger().info( "cmd_vel_logger started, listening to /panther/cmd_vel and /lynx/cmd_vel" ) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py index dd837c662..2b26bad28 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py @@ -12,8 +12,6 @@ from rclpy.node import Node from sensor_msgs.msg import NavSatFix -from ..utils import wait_for_subscriber - class _TestNavigationGPS: """Base class for navigation GPS tests. @@ -35,9 +33,6 @@ def test_goal_pose_gps( test_node (Node): The ROS 2 node to use for the test. navigation_degree_tolerance (float): The tolerance for navigation. timeout (int): The timeout in seconds. - - Raises: - TimeoutError: When a timeout occurs. """ timeout = 1 # TEST # 1) Obtain current GPS location: @@ -55,7 +50,7 @@ def callback(msg: NavSatFix) -> None: while current_nav_sat == NavSatFix(): rclpy.spin_once(test_node, timeout_sec=0) if time.time() - start_time > timeout: - raise TimeoutError("Timeout while waiting for current GPS location.") + break # 2) Publish goal GPS location 1e-5 degrees north of current location: goal_nav_sat = copy.deepcopy(current_nav_sat) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py index 8c6919ed7..36f881625 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py @@ -15,8 +15,6 @@ from tf2_ros.buffer import Buffer from tf2_ros.transform_listener import TransformListener -from ..utils import wait_for_subscriber - class _TestNavigationLidar: """Base class for lidar navigation tests. @@ -38,9 +36,6 @@ def test_goal_pose_lidar( test_node (Node): The ROS 2 node to use for the test. navigation_distance_tolerance (float): The tolerance for navigation. timeout (int): The timeout in seconds. - - Raises: - TimeoutError: When a timeout occurs. """ timeout = 1 # TEST # 1) Obtain current pose in map frame: @@ -56,7 +51,7 @@ def test_goal_pose_lidar( "map", f"{self.platforms['vehicle'].namespace}/base_link", Time() ) if time.time() - start_time > timeout: - raise TimeoutError() + break # 2) Publish goal pose 3 meter in front of current position: goal_pose = PoseStamped() diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index 6cb1d93b3..e8fb6c620 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -107,11 +107,11 @@ def callback(msg: NavSatFix) -> None: "panther", "lynx", "panther", - "lynx", - "panther", - "lynx", - "panther", - "lynx", + # "lynx", + # "panther", + # "lynx", + # "panther", + # "lynx", ] ): for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 0b5b72e4f..353327e4c 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -113,11 +113,11 @@ def test_goal_pose_lidar( "panther", "lynx", "panther", - "lynx", - "panther", - "lynx", - "panther", - "lynx", + # "lynx", + # "panther", + # "lynx", + # "panther", + # "lynx", ] ): for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): From e4fc3080b88980d6608d0b122b8765455f0ea006 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 7 Apr 2026 15:54:47 +0200 Subject: [PATCH 067/119] Let husarion's drive controller publish to different cmd_vel topic Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_gazebo/launch/gazebo.launch.py | 8 ++++---- .../src/alliander_husarion/launch/controllers.launch.py | 2 +- .../tests/test_nav2_absent_navigation_gps.py | 8 ++++---- .../tests/test_nav2_absent_navigation_lidar.py | 8 ++++---- .../alliander_tests/tests/test_nav2_navigation_gps.py | 9 +++++---- .../alliander_tests/tests/test_nav2_navigation_lidar.py | 9 +++++---- 6 files changed, 23 insertions(+), 21 deletions(-) diff --git a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py index 3100ad4ff..0ed27e53a 100644 --- a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py +++ b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py @@ -157,17 +157,17 @@ def launch_setup(context: LaunchContext) -> list: return [ Register.on_start(gazebo, context), - registerd_sleep(context), + # registerd_sleep(context), Register.on_log( bridge, "Creating GZ->ROS Bridge: [/clock (gz.msgs.Clock) -> /clock (rosgraph_msgs/msg/Clock)]", context, ), - registerd_sleep(context), + # registerd_sleep(context), Register.on_start(unpause_sim, context), - registerd_sleep(context), + # registerd_sleep(context), Register.on_exit(wait_for_clock, context), - registerd_sleep(context), + # registerd_sleep(context), Register.on_start(spawn_platforms, context), ] diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index 78c97ae4f..d3d89d348 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -69,7 +69,7 @@ def launch_setup(context: LaunchContext) -> list: "--controller-manager", "controller_manager", "--controller-ros-args", - "--remap drive_controller/cmd_vel:=cmd_vel", + "--remap drive_controller/cmd_vel:=cmd_vel_test", "--controller-ros-args", "--remap drive_controller/odom:=odometry/wheels", "--controller-ros-args", diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py index 2b26bad28..d5dd9cbb2 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py @@ -83,10 +83,10 @@ def callback(msg: NavSatFix) -> None: for i, vehicle in enumerate( [ "panther", - "lynx", - "panther", - "lynx", - "panther", + # "lynx", + # "panther", + # "lynx", + # "panther", # "lynx", # "panther", # "lynx", diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py index 36f881625..a3c2667a8 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py @@ -91,10 +91,10 @@ def test_goal_pose_lidar( for i, vehicle in enumerate( [ "panther", - "lynx", - "panther", - "lynx", - "panther", + # "lynx", + # "panther", + # "lynx", + # "panther", # "lynx", # "panther", # "lynx", diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index e8fb6c620..5c77328fa 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -39,6 +39,7 @@ def test_goal_pose_gps( Raises: TimeoutError: When a timeout occurs. """ + timeout = 2 # TEST # 1) Obtain current GPS location: current_nav_sat = NavSatFix() @@ -103,10 +104,10 @@ def callback(msg: NavSatFix) -> None: for i, vehicle in enumerate( [ "panther", - "lynx", - "panther", - "lynx", - "panther", + # "lynx", + # "panther", + # "lynx", + # "panther", # "lynx", # "panther", # "lynx", diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 353327e4c..5903bfaf9 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -42,6 +42,7 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ + timeout = 2 # TEST # 1) Obtain current pose in map frame: tf_buffer = Buffer() TransformListener(tf_buffer, test_node) @@ -109,10 +110,10 @@ def test_goal_pose_lidar( for i, vehicle in enumerate( [ "panther", - "lynx", - "panther", - "lynx", - "panther", + # "lynx", + # "panther", + # "lynx", + # "panther", # "lynx", # "panther", # "lynx", From e8ceeee71f727c1ed33638d987280f81c3bb903f Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 7 Apr 2026 16:31:13 +0200 Subject: [PATCH 068/119] Try again without nav2 Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d88c447e1..4cd307ef2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -205,4 +205,4 @@ jobs: - name: Install dependencies run: pip install pyyaml mashumaro - name: Run tests in alliander_tests container - run: python3 start.py --pytest-no-nvidia --mode all -k \"nav2_navigation_gps or nav2_navigation_lidar\" + run: python3 start.py --pytest-no-nvidia --mode all -k \"nav2_absent_navigation_gps or nav2_absent_navigation_lidar\" From b054e9c13ec0baa62abf61d28c43c70089497b0e Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 7 Apr 2026 17:02:00 +0200 Subject: [PATCH 069/119] Now switch back to nav2 version, remove all sleeps for now, let behaviour and controller servers publish cmd_vel to a different topic to see if they are also culprits Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 2 +- alliander_nav2/src/alliander_nav2/launch/nav2.launch.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4cd307ef2..d88c447e1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -205,4 +205,4 @@ jobs: - name: Install dependencies run: pip install pyyaml mashumaro - name: Run tests in alliander_tests container - run: python3 start.py --pytest-no-nvidia --mode all -k \"nav2_absent_navigation_gps or nav2_absent_navigation_lidar\" + run: python3 start.py --pytest-no-nvidia --mode all -k \"nav2_navigation_gps or nav2_navigation_lidar\" diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 500410922..6502f867e 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -253,6 +253,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 follow_path_params.file, ], namespace=namespace_vehicle, + remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_controller")], ) all_lifecycle_nodes["planner_server"] = LifecycleNode( @@ -272,6 +273,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 name="behavior_server", parameters=[behavior_server_params.file], namespace=namespace_vehicle, + remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_behavior")], ) all_lifecycle_nodes["bt_navigator"] = LifecycleNode( @@ -322,16 +324,16 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 registered_nodes_with_sleeps = [] for node in register_lifecycle_nodes: - registered_nodes_with_sleeps.append(registerd_sleep(context)) + # registered_nodes_with_sleeps.append(registerd_sleep(context)) registered_nodes_with_sleeps.append(Register.on_start(node, context)) return [ SetParameter(name="use_sim_time", value=vehicle_config.simulation), SetRemap(src="/cmd_vel", dst=pub_topic), *registered_nodes_with_sleeps, - registerd_sleep(context), + # registerd_sleep(context), Register.on_log(lifecycle_manager, "Managed nodes are active", context), - registerd_sleep(context), + # registerd_sleep(context), Register.on_log(nav2_manager, "Controller is ready.", context) if nav2.navigation else SKIP, From 5690b5f9d0b4ff77e3bc212db6f3a8b37ca005fd Mon Sep 17 00:00:00 2001 From: Rosalie Date: Tue, 7 Apr 2026 17:38:50 +0200 Subject: [PATCH 070/119] Test by listening to whether sim time is actually used Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src_py/cmd_vel_logger.py | 32 +++++++++---------- .../launch/controllers.launch.py | 2 +- .../src/alliander_nav2/launch/nav2.launch.py | 4 +-- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py index b126b257c..6e2d3d490 100755 --- a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py +++ b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py @@ -38,11 +38,11 @@ def __init__(self) -> None: def _callback_panther(self, msg: TwistStamped) -> None: self._check_nan(msg, "panther") - self._check_limits(msg) + self._check_limits(msg, "panther") def _callback_lynx(self, msg: TwistStamped) -> None: self._check_nan(msg, "lynx") - self._check_limits(msg) + self._check_limits(msg, "lynx") def _callback_cmd_vel(self, msg: TwistStamped) -> None: self.get_logger().warn( @@ -81,21 +81,21 @@ def _check_nan(self, msg: TwistStamped, namespace: str) -> None: f"type={pub.topic_type}" ) - def _check_limits(self, msg: TwistStamped) -> None: - msg = msg.twist - if abs(msg.linear.x) > MAX_LINEAR_X: - self.get_logger().warning( - f"Linear X velocity {msg.linear.x:.2f} m/s exceeds limit of {MAX_LINEAR_X} m/s" - ) - if abs(msg.linear.y) > MAX_LINEAR_Y: - self.get_logger().warning( - f"Unexpected lateral velocity linear.y={msg.linear.y:.2f} m/s " - f"(differential drive should not move sideways)" - ) - if abs(msg.angular.z) > MAX_ANGULAR_Z: - self.get_logger().warning( - f"Angular Z velocity {msg.angular.z:.2f} rad/s exceeds limit of {MAX_ANGULAR_Z} rad/s" + def _check_limits(self, msg: TwistStamped, namespace: str) -> None: + msg_seconds = msg.header.stamp.sec + second_duration = 10000 + if msg_seconds > second_duration: + self.get_logger().error( + f"cmd_vel not using sim_time!! Namely (in sec): {msg_seconds}" ) + publishers = self.get_publishers_info_by_topic(f"/{namespace}/cmd_vel") + for pub in publishers: + self.get_logger().info( + f"Publisher on /{namespace}/cmd_vel: " + f"node={pub.node_name}, " + f"namespace={pub.node_namespace}, " + f"type={pub.topic_type}" + ) def main() -> None: diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index d3d89d348..78c97ae4f 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -69,7 +69,7 @@ def launch_setup(context: LaunchContext) -> list: "--controller-manager", "controller_manager", "--controller-ros-args", - "--remap drive_controller/cmd_vel:=cmd_vel_test", + "--remap drive_controller/cmd_vel:=cmd_vel", "--controller-ros-args", "--remap drive_controller/odom:=odometry/wheels", "--controller-ros-args", diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 6502f867e..cc23cb648 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -253,7 +253,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 follow_path_params.file, ], namespace=namespace_vehicle, - remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_controller")], + # remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_controller")], ) all_lifecycle_nodes["planner_server"] = LifecycleNode( @@ -273,7 +273,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 name="behavior_server", parameters=[behavior_server_params.file], namespace=namespace_vehicle, - remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_behavior")], + # remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_behavior")], ) all_lifecycle_nodes["bt_navigator"] = LifecycleNode( From 2f4a24e674c2a16814fa361ce594570f73df7ebd Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 8 Apr 2026 08:43:32 +0200 Subject: [PATCH 071/119] Only let the behavior_server publish to /cmd_vel Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_husarion/launch/controllers.launch.py | 2 +- alliander_nav2/src/alliander_nav2/launch/nav2.launch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index 78c97ae4f..d3d89d348 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -69,7 +69,7 @@ def launch_setup(context: LaunchContext) -> list: "--controller-manager", "controller_manager", "--controller-ros-args", - "--remap drive_controller/cmd_vel:=cmd_vel", + "--remap drive_controller/cmd_vel:=cmd_vel_test", "--controller-ros-args", "--remap drive_controller/odom:=odometry/wheels", "--controller-ros-args", diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index cc23cb648..f46f5df1f 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -253,7 +253,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 follow_path_params.file, ], namespace=namespace_vehicle, - # remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_controller")], + remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_controller")], ) all_lifecycle_nodes["planner_server"] = LifecycleNode( From 96d0285eff7e3551ff531fc1a7d6041341849013 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 8 Apr 2026 11:39:52 +0200 Subject: [PATCH 072/119] Add twist_mux to husarion package to merge and (hopefully) filter invalid cmd_vel messages Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../alliander_husarion.Dockerfile | 7 ++++++ .../launch/controllers.launch.py | 23 ++++++++++++++++++- .../src/alliander_nav2/launch/nav2.launch.py | 4 ++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/alliander_husarion/alliander_husarion.Dockerfile b/alliander_husarion/alliander_husarion.Dockerfile index 985940005..e9e454e41 100644 --- a/alliander_husarion/alliander_husarion.Dockerfile +++ b/alliander_husarion/alliander_husarion.Dockerfile @@ -7,6 +7,13 @@ FROM $BASE_IMAGE ARG COLCON_BUILD_SEQUENTIAL ENV ROS_DISTRO=jazzy +# Install ROS dependencies +RUN apt update && apt install -y --no-install-recommends \ + ros-$ROS_DISTRO-twist-mux \ + && rm -rf /var/lib/apt/lists/* \ + && apt autoremove -y \ + && apt clean + # Install Husarion packages WORKDIR /$WORKDIR/external RUN apt update \ diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index d3d89d348..b2395b190 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -25,6 +25,26 @@ def launch_setup(context: LaunchContext) -> list: """ vehicle_config = Vehicle.from_str(platform_arg.string_value(context)) + twist_mux = Node( + package="twist_mux", + executable="twist_mux", + name="twist_mux", + namespace=vehicle_config.namespace, + parameters=[ + {"use_stamped": True}, + { + "topics": { + "navigation": { + "topic": "cmd_vel_nav", + "timeout": 0.5, + "priority": 10, + } + } + }, + ], + remappings=[("cmd_vel_out", "cmd_vel")], + ) + joint_state_broadcaster_spawner = Node( package="controller_manager", executable="spawner", @@ -69,7 +89,7 @@ def launch_setup(context: LaunchContext) -> list: "--controller-manager", "controller_manager", "--controller-ros-args", - "--remap drive_controller/cmd_vel:=cmd_vel_test", + "--remap drive_controller/cmd_vel:=cmd_vel_nav", "--controller-ros-args", "--remap drive_controller/odom:=odometry/wheels", "--controller-ros-args", @@ -80,6 +100,7 @@ def launch_setup(context: LaunchContext) -> list: ) return [ + Register.on_start(twist_mux, context), Register.on_exit(joint_state_broadcaster_spawner, context), Register.on_exit(imu_broadcaster_spawner, context), Register.on_exit(drive_controller_spawner, context), diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index f46f5df1f..1c64168e8 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -253,7 +253,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 follow_path_params.file, ], namespace=namespace_vehicle, - remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_controller")], + remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_nav")], ) all_lifecycle_nodes["planner_server"] = LifecycleNode( @@ -273,7 +273,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 name="behavior_server", parameters=[behavior_server_params.file], namespace=namespace_vehicle, - # remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_behavior")], + remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_nav")], ) all_lifecycle_nodes["bt_navigator"] = LifecycleNode( From 2c5da818343bcdbe8bc004fcc10b7f1f0814afae Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 8 Apr 2026 12:51:30 +0200 Subject: [PATCH 073/119] Check again whether no publishers to cmd_vel (and mismatch between husarion and nav2 package) causes no nans Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_husarion/launch/controllers.launch.py | 4 ++-- .../src/alliander_tests/tests/test_nav2_navigation_gps.py | 2 +- .../src/alliander_tests/tests/test_nav2_navigation_lidar.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index b2395b190..30b0fb263 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -89,7 +89,7 @@ def launch_setup(context: LaunchContext) -> list: "--controller-manager", "controller_manager", "--controller-ros-args", - "--remap drive_controller/cmd_vel:=cmd_vel_nav", + "--remap drive_controller/cmd_vel:=cmd_vel_nav2", "--controller-ros-args", "--remap drive_controller/odom:=odometry/wheels", "--controller-ros-args", @@ -100,7 +100,7 @@ def launch_setup(context: LaunchContext) -> list: ) return [ - Register.on_start(twist_mux, context), + # Register.on_start(twist_mux, context), Register.on_exit(joint_state_broadcaster_spawner, context), Register.on_exit(imu_broadcaster_spawner, context), Register.on_exit(drive_controller_spawner, context), diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index 5c77328fa..758277bbb 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -104,7 +104,7 @@ def callback(msg: NavSatFix) -> None: for i, vehicle in enumerate( [ "panther", - # "lynx", + "lynx", # "panther", # "lynx", # "panther", diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 5903bfaf9..20de4f63c 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -110,7 +110,7 @@ def test_goal_pose_lidar( for i, vehicle in enumerate( [ "panther", - # "lynx", + "lynx", # "panther", # "lynx", # "panther", From 8c2f100b582aaf472b3e875f36f20ea8e1ad25c9 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 8 Apr 2026 13:19:09 +0200 Subject: [PATCH 074/119] Immediately start running gazebo Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py index 0ed27e53a..0b0857b19 100644 --- a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py +++ b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py @@ -109,7 +109,7 @@ def launch_setup(context: LaunchContext) -> list: else: world_name = world_attribute.attrib.get("name") - cmd = ["gz", "sim", sdf_file] + cmd: list[str] = ["gz", "sim", "-r", sdf_file] if not config.load_ui: cmd.append("-s") gazebo = ExecuteProcess( @@ -164,7 +164,7 @@ def launch_setup(context: LaunchContext) -> list: context, ), # registerd_sleep(context), - Register.on_start(unpause_sim, context), + # Register.on_start(unpause_sim, context), # registerd_sleep(context), Register.on_exit(wait_for_clock, context), # registerd_sleep(context), From 6cd24ed3ea36a2a208d193635c35a49246ed4220 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 8 Apr 2026 14:36:00 +0200 Subject: [PATCH 075/119] Properly update twist_mux usage and also link gazebo urdf correctly Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../husarion/urdf/original/gazebo.urdf.xacro | 2 +- .../src_py/cmd_vel_logger.py | 46 ++++++++++++------- .../launch/controllers.launch.py | 6 +-- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro b/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro index e451f3167..ab3ec829f 100644 --- a/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro +++ b/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro @@ -37,7 +37,7 @@ ${config_file} ${namespace} - drive_controller/cmd_vel:=cmd_vel + drive_controller/cmd_vel:=cmd_vel_test drive_controller/odom:=odometry/wheels drive_controller/transition_event:=drive_controller/_transition_event imu_broadcaster/imu:=imu/data diff --git a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py index 6e2d3d490..04e570206 100755 --- a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py +++ b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py @@ -35,16 +35,28 @@ def __init__(self) -> None: self.get_logger().info( "cmd_vel_logger started, listening to /panther/cmd_vel and /lynx/cmd_vel" ) + self.last_time = 0 def _callback_panther(self, msg: TwistStamped) -> None: + self.get_logger().warn( + f"Found data on /panther/cmd_vel, with timestamp: {msg.header.stamp}" + ) self._check_nan(msg, "panther") - self._check_limits(msg, "panther") + self._check_timing(msg, "/panther/cmd_vel") def _callback_lynx(self, msg: TwistStamped) -> None: + self.get_logger().warn( + f"Found data on /lynx/cmd_vel, with timestamp: {msg.header.stamp}" + ) self._check_nan(msg, "lynx") - self._check_limits(msg, "lynx") + self._check_timing(msg, "/lynx/cmd_vel") def _callback_cmd_vel(self, msg: TwistStamped) -> None: + self.get_logger().warn( + f"Found data on /cmd_vel, with timestamp: {msg.header.stamp}" + ) + + def _callback_drive_cmd_vel(self, msg: TwistStamped) -> None: self.get_logger().warn( f"Found data on /drive_controller/cmd_vel, with timestamp: {msg.header.stamp}" ) @@ -81,22 +93,24 @@ def _check_nan(self, msg: TwistStamped, namespace: str) -> None: f"type={pub.topic_type}" ) - def _check_limits(self, msg: TwistStamped, namespace: str) -> None: - msg_seconds = msg.header.stamp.sec - second_duration = 10000 - if msg_seconds > second_duration: - self.get_logger().error( - f"cmd_vel not using sim_time!! Namely (in sec): {msg_seconds}" - ) - publishers = self.get_publishers_info_by_topic(f"/{namespace}/cmd_vel") - for pub in publishers: - self.get_logger().info( - f"Publisher on /{namespace}/cmd_vel: " - f"node={pub.node_name}, " - f"namespace={pub.node_namespace}, " - f"type={pub.topic_type}" + def _check_timing(self, msg: TwistStamped, topic: str) -> None: + now = msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9 + + if self.last_time is not None: + dt = now - self.last_time + + if dt > 0.5: # noqa: PLR2004 + self.get_logger().warn( + f"[{topic}] Large time gap detected: dt={dt:.3f}s (clock pause?)" ) + if dt < 0: + self.get_logger().error( + f"[{topic}] Time went backwards! dt={dt:.6f}" + ) + + self.last_time = now + def main() -> None: """Test.""" diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index 30b0fb263..399079ba2 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -42,7 +42,7 @@ def launch_setup(context: LaunchContext) -> list: } }, ], - remappings=[("cmd_vel_out", "cmd_vel")], + remappings=[("cmd_vel_out", "cmd_vel_test")], ) joint_state_broadcaster_spawner = Node( @@ -89,7 +89,7 @@ def launch_setup(context: LaunchContext) -> list: "--controller-manager", "controller_manager", "--controller-ros-args", - "--remap drive_controller/cmd_vel:=cmd_vel_nav2", + "--remap drive_controller/cmd_vel:=cmd_vel_test", "--controller-ros-args", "--remap drive_controller/odom:=odometry/wheels", "--controller-ros-args", @@ -100,7 +100,7 @@ def launch_setup(context: LaunchContext) -> list: ) return [ - # Register.on_start(twist_mux, context), + Register.on_start(twist_mux, context), Register.on_exit(joint_state_broadcaster_spawner, context), Register.on_exit(imu_broadcaster_spawner, context), Register.on_exit(drive_controller_spawner, context), From d94d48506a58c6610bf1f8901d9909d187d91796 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Wed, 8 Apr 2026 15:19:01 +0200 Subject: [PATCH 076/119] Run actual tests again Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_tests/tests/test_nav2_navigation_gps.py | 3 +-- .../src/alliander_tests/tests/test_nav2_navigation_lidar.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index 758277bbb..640681289 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -39,7 +39,6 @@ def test_goal_pose_gps( Raises: TimeoutError: When a timeout occurs. """ - timeout = 2 # TEST # 1) Obtain current GPS location: current_nav_sat = NavSatFix() @@ -104,7 +103,7 @@ def callback(msg: NavSatFix) -> None: for i, vehicle in enumerate( [ "panther", - "lynx", + # "lynx", # "panther", # "lynx", # "panther", diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 20de4f63c..51b336598 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -42,7 +42,6 @@ def test_goal_pose_lidar( Raises: TimeoutError: When a timeout occurs. """ - timeout = 2 # TEST # 1) Obtain current pose in map frame: tf_buffer = Buffer() TransformListener(tf_buffer, test_node) @@ -110,7 +109,7 @@ def test_goal_pose_lidar( for i, vehicle in enumerate( [ "panther", - "lynx", + # "lynx", # "panther", # "lynx", # "panther", From 861b820f19f13cbd2c6e6b94c558dd8e38ac3b0c Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 09:14:54 +0200 Subject: [PATCH 077/119] Start cleaning up the code as the twist_mux seems to work Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src_py/cmd_vel_logger.py | 4 +- .../alliander_gazebo/launch/gazebo.launch.py | 38 +----- .../alliander_gazebo/src_py/wait_for_clock.py | 47 ------- .../src/alliander_nav2/launch/nav2.launch.py | 54 +++----- .../tests/test_nav2_absent_navigation_gps.py | 115 ------------------ .../test_nav2_absent_navigation_lidar.py | 114 ----------------- 6 files changed, 18 insertions(+), 354 deletions(-) delete mode 100755 alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py delete mode 100644 alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py delete mode 100644 alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py diff --git a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py index 04e570206..e6ebf661b 100755 --- a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py +++ b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py @@ -105,9 +105,7 @@ def _check_timing(self, msg: TwistStamped, topic: str) -> None: ) if dt < 0: - self.get_logger().error( - f"[{topic}] Time went backwards! dt={dt:.6f}" - ) + self.get_logger().error(f"[{topic}] Time went backwards! dt={dt:.6f}") self.last_time = now diff --git a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py index 0b0857b19..d90d7bf45 100644 --- a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py +++ b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py @@ -109,7 +109,7 @@ def launch_setup(context: LaunchContext) -> list: else: world_name = world_attribute.attrib.get("name") - cmd: list[str] = ["gz", "sim", "-r", sdf_file] + cmd: list[str] = ["gz", "sim", sdf_file] if not config.load_ui: cmd.append("-s") gazebo = ExecuteProcess( @@ -149,50 +149,18 @@ def launch_setup(context: LaunchContext) -> list: shell=False, ) - wait_for_clock = Node( - package="alliander_gazebo", - executable="wait_for_clock.py", - output="screen", - ) - return [ Register.on_start(gazebo, context), - # registerd_sleep(context), Register.on_log( bridge, "Creating GZ->ROS Bridge: [/clock (gz.msgs.Clock) -> /clock (rosgraph_msgs/msg/Clock)]", context, ), - # registerd_sleep(context), - # Register.on_start(unpause_sim, context), - # registerd_sleep(context), - Register.on_exit(wait_for_clock, context), - # registerd_sleep(context), - Register.on_start(spawn_platforms, context), + Register.on_log(spawn_platforms, "All platforms spawned!", context), + Register.on_start(unpause_sim, context), ] -def registerd_sleep(context: LaunchContext) -> LaunchDescription: - """Create a sleep. - - Args: - context (LaunchContext): The launch context. - - Returns: - LaunchDescription: The launch description containing the sleep action. - """ - return Register.on_exit( - ExecuteProcess( - cmd=[ - "sleep", - "5", - ], - shell=False, - ), - context, - ) - - def generate_launch_description() -> LaunchDescription: """Generate the launch description for the Gazebo simulation with platforms. diff --git a/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py b/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py deleted file mode 100755 index 3a1c58a7d..000000000 --- a/alliander_gazebo/src/alliander_gazebo/src_py/wait_for_clock.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: Alliander N. V. -# -# SPDX-License-Identifier: Apache-2.0 - -import rclpy -from rclpy.node import Node -from rosgraph_msgs.msg import Clock - - -class WaitForClock(Node): - """Test.""" - - def __init__(self): - """Test.""" - super().__init__("wait_for_clock") - self.received = False - self.create_subscription(Clock, "/clock", self.cb, 10) - - def cb(self, msg: Clock) -> None: # noqa: ARG002 - """Test. - - Args: - msg (Clock): test. - """ - self.get_logger().info(f"Received /clock: {msg.clock}, continuing...") - self.received = True - - -def main(args: list | None = None) -> None: - """Test. - - Args: - args (list | None): Command line arguments, defaults to None. - """ - rclpy.init(args=args) - node = WaitForClock() - - while rclpy.ok() and not node.received: - rclpy.spin_once(node, timeout_sec=0.1) - - node.destroy_node() - rclpy.shutdown() - - -if __name__ == "__main__": - main() diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 1c64168e8..df6ae3abe 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -9,13 +9,13 @@ from alliander_utilities.register import Register from alliander_utilities.ros_utils import get_file_path from launch import LaunchContext, LaunchDescription -from launch.actions import ExecuteProcess, OpaqueFunction +from launch.actions import OpaqueFunction from launch_ros.actions import LifecycleNode, Node, SetParameter, SetRemap platform_arg = LaunchArgument("platform_config", "") -def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 +def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 """The launch setup. Args: @@ -253,7 +253,9 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 follow_path_params.file, ], namespace=namespace_vehicle, - remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_nav")], + remappings=[ + (f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_nav") + ], ) all_lifecycle_nodes["planner_server"] = LifecycleNode( @@ -273,7 +275,9 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 name="behavior_server", parameters=[behavior_server_params.file], namespace=namespace_vehicle, - remappings=[(f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_nav")], + remappings=[ + (f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_nav") + ], ) all_lifecycle_nodes["bt_navigator"] = LifecycleNode( @@ -312,55 +316,25 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 remappings=remappings, ) - pub_topic = ( - f"/{namespace_vehicle}/cmd_vel" - if not nav2.collision_monitor - else f"/{namespace_vehicle}/cmd_vel_raw" - ) - register_lifecycle_nodes = [] for node_name in lifecycle_nodes_names: register_lifecycle_nodes.append(all_lifecycle_nodes[node_name]) - registered_nodes_with_sleeps = [] - for node in register_lifecycle_nodes: - # registered_nodes_with_sleeps.append(registerd_sleep(context)) - registered_nodes_with_sleeps.append(Register.on_start(node, context)) - return [ SetParameter(name="use_sim_time", value=vehicle_config.simulation), - SetRemap(src="/cmd_vel", dst=pub_topic), - *registered_nodes_with_sleeps, - # registerd_sleep(context), + SetRemap( + src=f"/{namespace_vehicle}/cmd_vel", dst=f"/{namespace_vehicle}/cmd_vel_raw" + ) + if nav2.collision_monitor + else SKIP, + *[Register.on_start(node, context) for node in register_lifecycle_nodes], Register.on_log(lifecycle_manager, "Managed nodes are active", context), - # registerd_sleep(context), Register.on_log(nav2_manager, "Controller is ready.", context) if nav2.navigation else SKIP, ] -def registerd_sleep(context: LaunchContext) -> LaunchDescription: - """Create a sleep. - - Args: - context (LaunchContext): The launch context. - - Returns: - LaunchDescription: The launch description containing the sleep action. - """ - return Register.on_exit( - ExecuteProcess( - cmd=[ - "sleep", - "5", - ], - shell=False, - ), - context, - ) - - def generate_launch_description() -> LaunchDescription: """Generate the launch description for the navigation stack. diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py deleted file mode 100644 index d5dd9cbb2..000000000 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_gps.py +++ /dev/null @@ -1,115 +0,0 @@ -# SPDX-FileCopyrightText: Alliander N. V. -# -# SPDX-License-Identifier: Apache-2.0 - -import copy -import sys -import time - -import rclpy -from alliander_utilities.config_objects import GPS, Lidar, Vehicle, link -from geographic_msgs.msg import GeoPath, GeoPoseStamped -from rclpy.node import Node -from sensor_msgs.msg import NavSatFix - - -class _TestNavigationGPS: - """Base class for navigation GPS tests. - - Attributes: - platforms (dict): A dictionary of the platforms to launch. - world (str): The world to launch. - """ - - platforms: dict - world: str = "map_5.940906_51.966960" - - def test_goal_pose_gps( - self, test_node: Node, navigation_degree_tolerance: float, timeout: int - ) -> None: - """Test that navigation to a goal pose is successful. - - Args: - test_node (Node): The ROS 2 node to use for the test. - navigation_degree_tolerance (float): The tolerance for navigation. - timeout (int): The timeout in seconds. - """ - timeout = 1 # TEST - # 1) Obtain current GPS location: - current_nav_sat = NavSatFix() - - def callback(msg: NavSatFix) -> None: - current_nav_sat.latitude = msg.latitude - current_nav_sat.longitude = msg.longitude - - test_node.create_subscription( - NavSatFix, f"/{self.platforms['gps'].namespace}/gps/fix", callback, 10 - ) - - start_time = time.time() - while current_nav_sat == NavSatFix(): - rclpy.spin_once(test_node, timeout_sec=0) - if time.time() - start_time > timeout: - break - - # 2) Publish goal GPS location 1e-5 degrees north of current location: - goal_nav_sat = copy.deepcopy(current_nav_sat) - goal_nav_sat.latitude += 1e-5 - - publisher = test_node.create_publisher(GeoPath, "/gps_waypoints", 10) - goal_msg = GeoPath() - goal_pose = GeoPoseStamped() - goal_pose.pose.position.latitude = goal_nav_sat.latitude - goal_pose.pose.position.longitude = goal_nav_sat.longitude - goal_msg.poses.append(goal_pose) - publisher.publish(goal_msg) - - # 3) Wait until goal is reached within tolerance: - start_time = time.time() - distance: float = sys.float_info.max - last_log_time = 0.0 - - while distance > navigation_degree_tolerance: - rclpy.spin_once(test_node, timeout_sec=0) - distance = abs(current_nav_sat.latitude - goal_nav_sat.latitude) - now = time.time() - if now - last_log_time >= 1.0: - test_node.get_logger().info(f"Distance to goal: {distance}") - last_log_time = now - if time.time() - start_time > timeout: - break - - -for i, vehicle in enumerate( - [ - "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - ] -): - for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): - for gps in ["gps"]: - vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) - lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) - gps_platform = GPS(gps, (0, 0, 0.2)) - link(vehicle_platform, lidar_platform) - link(vehicle_platform, gps_platform) - test_class = type( - f"Test{vehicle.capitalize()}{lidar.capitalize()}{gps.capitalize()}AbsentNavigation{i}.{j}", - (_TestNavigationGPS,), - { - "platforms": { - "vehicle": vehicle_platform, - "lidar": lidar_platform, - "gps": gps_platform, - } - }, - ) - globals()[test_class.__name__] = test_class diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py deleted file mode 100644 index a3c2667a8..000000000 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_absent_navigation_lidar.py +++ /dev/null @@ -1,114 +0,0 @@ -# SPDX-FileCopyrightText: Alliander N. V. -# -# SPDX-License-Identifier: Apache-2.0 - -import contextlib -import sys -import time - -import rclpy -from alliander_utilities.config_objects import Lidar, Vehicle, link -from geometry_msgs.msg import PoseStamped, TransformStamped -from rclpy.node import Node -from rclpy.time import Time -from tf2_ros import TransformException # ty: ignore[unresolved-import] -from tf2_ros.buffer import Buffer -from tf2_ros.transform_listener import TransformListener - - -class _TestNavigationLidar: - """Base class for lidar navigation tests. - - Attributes: - platforms (dict): A dictionary of the platforms to launch. - world (str): The world to launch. - """ - - platforms: dict - world: str = "test_navigation.sdf" - - def test_goal_pose_lidar( - self, test_node: Node, navigation_distance_tolerance: float, timeout: int - ) -> None: - """Test that navigation to a goal pose is successful. - - Args: - test_node (Node): The ROS 2 node to use for the test. - navigation_distance_tolerance (float): The tolerance for navigation. - timeout (int): The timeout in seconds. - """ - timeout = 1 # TEST - # 1) Obtain current pose in map frame: - tf_buffer = Buffer() - TransformListener(tf_buffer, test_node) - current_pose = TransformStamped() - - start_time = time.time() - while current_pose == TransformStamped(): - rclpy.spin_once(test_node, timeout_sec=0) - with contextlib.suppress(TransformException): - current_pose = tf_buffer.lookup_transform( - "map", f"{self.platforms['vehicle'].namespace}/base_link", Time() - ) - if time.time() - start_time > timeout: - break - - # 2) Publish goal pose 3 meter in front of current position: - goal_pose = PoseStamped() - goal_pose.header.frame_id = "map" - goal_pose.pose.position.x = current_pose.transform.translation.x + 3 - goal_pose.pose.position.y = current_pose.transform.translation.y - goal_pose.pose.position.z = current_pose.transform.translation.z - - publisher = test_node.create_publisher(PoseStamped, "/goal_pose", 10) - publisher.publish(goal_pose) - test_node.get_logger().info("Published goal pose for navigation.") - - # 3) Wait until goal is reached within tolerance: - start_time = time.time() - distance: float = sys.float_info.max - last_log_time = 0.0 - - while distance > navigation_distance_tolerance: - rclpy.spin_once(test_node, timeout_sec=0) - with contextlib.suppress(TransformException): - current_pose = tf_buffer.lookup_transform( - "map", f"{self.platforms['vehicle'].namespace}/base_link", Time() - ) - distance = abs( - current_pose.transform.translation.x - goal_pose.pose.position.x - ) - now = time.time() - if now - last_log_time >= 1.0: - test_node.get_logger().info(f"Distance to goal: {distance:.6f}m") - last_log_time = now - if time.time() - start_time > timeout: - break - - test_node.get_logger().info(f"Final distance to goal: {distance}.") - - -for i, vehicle in enumerate( - [ - "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - ] -): - for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): - vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) - lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) - link(vehicle_platform, lidar_platform) - test_class = type( - f"Test{vehicle.capitalize()}{lidar.capitalize()}AbsentNavigation{i}.{j}", - (_TestNavigationLidar,), - {"platforms": {"vehicle": vehicle_platform, "lidar": lidar_platform}}, - ) - globals()[test_class.__name__] = test_class From 621bab83d2f5f3e6281bd852d99c0fa1a2eec691 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 10:45:19 +0200 Subject: [PATCH 078/119] Revert diagnostics changes and adjust joystick publishing topic Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../alliander_diagnostics.Dockerfile | 2 +- .../src/alliander_diagnostics/CMakeLists.txt | 7 - .../launch/diagnostics.launch.py | 7 - .../src_py/cmd_vel_logger.py | 127 ------------------ .../launch/controllers.launch.py | 5 + .../launch/joystick.launch.py | 2 +- .../config/gamepad_mapping.yaml | 11 -- .../tests/test_nav2_navigation_gps.py | 19 +-- .../tests/test_nav2_navigation_lidar.py | 19 +-- pyproject.toml | 3 - uv.lock | 4 - 11 files changed, 13 insertions(+), 193 deletions(-) delete mode 100755 alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py delete mode 100644 alliander_nav2/src/alliander_nav2/config/gamepad_mapping.yaml diff --git a/alliander_diagnostics/alliander_diagnostics.Dockerfile b/alliander_diagnostics/alliander_diagnostics.Dockerfile index 57b60ead3..1de4bee7b 100644 --- a/alliander_diagnostics/alliander_diagnostics.Dockerfile +++ b/alliander_diagnostics/alliander_diagnostics.Dockerfile @@ -16,7 +16,7 @@ RUN /$WORKDIR/colcon_build.sh # Install python dependencies: WORKDIR $WORKDIR COPY pyproject.toml /$WORKDIR/pyproject.toml -RUN uv sync --group alliander-diagnostics \ +RUN uv sync \ && echo "export PYTHONPATH=\"$(dirname $(dirname $(uv python find)))/lib/python3.12/site-packages:\$PYTHONPATH\"" >> /root/.bashrc \ && echo "export PATH=\"$(dirname $(dirname $(uv python find)))/bin:\$PATH\"" >> /root/.bashrc diff --git a/alliander_diagnostics/src/alliander_diagnostics/CMakeLists.txt b/alliander_diagnostics/src/alliander_diagnostics/CMakeLists.txt index c60abc14d..1b3b0e011 100644 --- a/alliander_diagnostics/src/alliander_diagnostics/CMakeLists.txt +++ b/alliander_diagnostics/src/alliander_diagnostics/CMakeLists.txt @@ -7,19 +7,12 @@ project(alliander_diagnostics) # CMake dependencies: find_package(ament_cmake REQUIRED) -find_package(ament_cmake_python REQUIRED) # Other dependencies: find_package(rclcpp REQUIRED) find_package(diagnostic_msgs REQUIRED) find_package(sensor_msgs REQUIRED) -# Python executables: -install( - DIRECTORY src_py/ - DESTINATION lib/${PROJECT_NAME} -) - # C++ executables: include_directories(include) add_executable(${PROJECT_NAME}_node diff --git a/alliander_diagnostics/src/alliander_diagnostics/launch/diagnostics.launch.py b/alliander_diagnostics/src/alliander_diagnostics/launch/diagnostics.launch.py index 007215ba7..f59b4a41b 100644 --- a/alliander_diagnostics/src/alliander_diagnostics/launch/diagnostics.launch.py +++ b/alliander_diagnostics/src/alliander_diagnostics/launch/diagnostics.launch.py @@ -72,17 +72,10 @@ def launch_setup(context: LaunchContext) -> list: parameters=[parameters], ) - cmd_vel_logger_node = Node( - package="alliander_diagnostics", - executable="cmd_vel_logger.py", - name="cmd_vel_logger", - ) - use_sim_time = use_sim_time_arg.bool_value(context) return [ SetParameter(name="use_sim_time", value=use_sim_time), - Register.on_start(cmd_vel_logger_node, context), Register.on_start(diagnostics_node, context), ] diff --git a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py b/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py deleted file mode 100755 index e6ebf661b..000000000 --- a/alliander_diagnostics/src/alliander_diagnostics/src_py/cmd_vel_logger.py +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: Alliander N. V. -# -# SPDX-License-Identifier: Apache-2.0 -import math - -import rclpy -from geometry_msgs.msg import TwistStamped -from rclpy.node import Node - -# Thresholds for abnormal behaviour: -MAX_LINEAR_X = 2.0 # m/s -MAX_LINEAR_Y = 0.1 # m/s — differential drive should not move sideways -MAX_ANGULAR_Z = 2.0 # rad/s - - -class CmdVelLogger(Node): - """Test.""" - - def __init__(self) -> None: - """Test.""" - super().__init__("cmd_vel_logger") - self.sub_panther = self.create_subscription( - TwistStamped, "/panther/cmd_vel", self._callback_panther, 10 - ) - self.sub_lynx = self.create_subscription( - TwistStamped, "/lynx/cmd_vel", self._callback_lynx, 10 - ) - self.sub_drive_controller = self.create_subscription( - TwistStamped, "/drive_controller/cmd_vel", self._callback_cmd_vel, 10 - ) - self.sub_cmd_vel = self.create_subscription( - TwistStamped, "/cmd_vel", self._callback_cmd_vel, 10 - ) - self.get_logger().info( - "cmd_vel_logger started, listening to /panther/cmd_vel and /lynx/cmd_vel" - ) - self.last_time = 0 - - def _callback_panther(self, msg: TwistStamped) -> None: - self.get_logger().warn( - f"Found data on /panther/cmd_vel, with timestamp: {msg.header.stamp}" - ) - self._check_nan(msg, "panther") - self._check_timing(msg, "/panther/cmd_vel") - - def _callback_lynx(self, msg: TwistStamped) -> None: - self.get_logger().warn( - f"Found data on /lynx/cmd_vel, with timestamp: {msg.header.stamp}" - ) - self._check_nan(msg, "lynx") - self._check_timing(msg, "/lynx/cmd_vel") - - def _callback_cmd_vel(self, msg: TwistStamped) -> None: - self.get_logger().warn( - f"Found data on /cmd_vel, with timestamp: {msg.header.stamp}" - ) - - def _callback_drive_cmd_vel(self, msg: TwistStamped) -> None: - self.get_logger().warn( - f"Found data on /drive_controller/cmd_vel, with timestamp: {msg.header.stamp}" - ) - publishers = self.get_publishers_info_by_topic("/drive_controller/cmd_vel") - for pub in publishers: - self.get_logger().info( - f"Publisher on /drive_controller/cmd_vel: " - f"node={pub.node_name}, " - f"namespace={pub.node_namespace}, " - f"type={pub.topic_type}" - ) - - def _check_nan(self, msg: TwistStamped, namespace: str) -> None: - msg = msg.twist - values = { - "linear.x": msg.linear.x, - "linear.y": msg.linear.y, - "linear.z": msg.linear.z, - "angular.x": msg.angular.x, - "angular.y": msg.angular.y, - "angular.z": msg.angular.z, - } - for field, value in values.items(): - if math.isnan(value) or math.isinf(value) or not math.isfinite(value): - self.get_logger().error( - f"cmd_vel contains invalid value in {field}: {value}" - ) - publishers = self.get_publishers_info_by_topic(f"/{namespace}/cmd_vel") - for pub in publishers: - self.get_logger().info( - f"Publisher on /{namespace}/cmd_vel: " - f"node={pub.node_name}, " - f"namespace={pub.node_namespace}, " - f"type={pub.topic_type}" - ) - - def _check_timing(self, msg: TwistStamped, topic: str) -> None: - now = msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9 - - if self.last_time is not None: - dt = now - self.last_time - - if dt > 0.5: # noqa: PLR2004 - self.get_logger().warn( - f"[{topic}] Large time gap detected: dt={dt:.3f}s (clock pause?)" - ) - - if dt < 0: - self.get_logger().error(f"[{topic}] Time went backwards! dt={dt:.6f}") - - self.last_time = now - - -def main() -> None: - """Test.""" - rclpy.init() - node = CmdVelLogger() - try: - rclpy.spin(node) - except KeyboardInterrupt: - pass - finally: - node.destroy_node() - rclpy.shutdown() - - -if __name__ == "__main__": - main() diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index 399079ba2..08c4b5b6d 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -38,6 +38,11 @@ def launch_setup(context: LaunchContext) -> list: "topic": "cmd_vel_nav", "timeout": 0.5, "priority": 10, + }, + "joystick": { + "topic": "cmd_vel_joy", + "timeout": 0.5, + "priority": 100, } } }, diff --git a/alliander_joystick/src/alliander_joystick/launch/joystick.launch.py b/alliander_joystick/src/alliander_joystick/launch/joystick.launch.py index d8085a94b..51bd25a55 100644 --- a/alliander_joystick/src/alliander_joystick/launch/joystick.launch.py +++ b/alliander_joystick/src/alliander_joystick/launch/joystick.launch.py @@ -75,7 +75,7 @@ def launch_setup(context: LaunchContext) -> list: "arm_home_service": f"{arm_namespace}/moveit_manager/move_to_configuration" }, {"arm_pause_servo_service": f"{arm_namespace}/servo_node/pause_servo"}, - {"vehicle_cmd_topic": f"{vehicle_namespace}/cmd_vel"}, + {"vehicle_cmd_topic": f"{vehicle_namespace}/cmd_vel_joy"}, {"vehicle_estop_reset": f"{vehicle_namespace}/hardware/e_stop_reset"}, {"vehicle_estop_trigger": f"{vehicle_namespace}/hardware/e_stop_trigger"}, ], diff --git a/alliander_nav2/src/alliander_nav2/config/gamepad_mapping.yaml b/alliander_nav2/src/alliander_nav2/config/gamepad_mapping.yaml deleted file mode 100644 index 75e86f0ca..000000000 --- a/alliander_nav2/src/alliander_nav2/config/gamepad_mapping.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# SPDX-FileCopyrightText: Alliander N. V. -# -# SPDX-License-Identifier: Apache-2.0 - -axes: - 1: - linear: - direction: "x" - 2: - angular: - direction: "z" diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index 640681289..017acf40c 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -100,21 +100,8 @@ def callback(msg: NavSatFix) -> None: ) -for i, vehicle in enumerate( - [ - "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - ] -): - for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): +for vehicle in ["panther", "lynx",]: + for lidar in ["velodyne", "ouster"]: for gps in ["gps"]: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) @@ -124,7 +111,7 @@ def callback(msg: NavSatFix) -> None: vehicle_platform.nav2_config.navigation = True vehicle_platform.nav2_config.gps = True test_class = type( - f"Test{vehicle.capitalize()}{lidar.capitalize()}{gps.capitalize()}Navigation{i}.{j}", + f"Test{vehicle.capitalize()}{lidar.capitalize()}{gps.capitalize()}Navigation", (_TestNavigationGPS,), { "platforms": { diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 51b336598..a155adf9e 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -106,27 +106,14 @@ def test_goal_pose_lidar( ) -for i, vehicle in enumerate( - [ - "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - # "panther", - # "lynx", - ] -): - for j, lidar in enumerate(["velodyne", "ouster", "velodyne", "ouster", "velodyne"]): +for vehicle in ["panther", "lynx",]: + for lidar in ["velodyne", "ouster"]: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) link(vehicle_platform, lidar_platform) vehicle_platform.nav2_config.navigation = True test_class = type( - f"Test{vehicle.capitalize()}{lidar.capitalize()}Navigation{i}.{j}", + f"Test{vehicle.capitalize()}{lidar.capitalize()}LidarNavigation", (_TestNavigationLidar,), {"platforms": {"vehicle": vehicle_platform, "lidar": lidar_platform}}, ) diff --git a/pyproject.toml b/pyproject.toml index 42f501deb..680dfea9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,9 +32,6 @@ linting = [ "ruff>=0.14.13", "ty>=0.0.12", ] -alliander-diagnostics = [ - "numpy>=2.4.1", -] alliander-franka = [ "numpy>=2.4.1", ] diff --git a/uv.lock b/uv.lock index 42280d665..1893cfb35 100644 --- a/uv.lock +++ b/uv.lock @@ -89,9 +89,6 @@ dependencies = [ ] [package.dev-dependencies] -alliander-diagnostics = [ - { name = "numpy" }, -] alliander-franka = [ { name = "numpy" }, ] @@ -156,7 +153,6 @@ requires-dist = [ ] [package.metadata.requires-dev] -alliander-diagnostics = [{ name = "numpy", specifier = ">=2.4.1" }] alliander-franka = [{ name = "numpy", specifier = ">=2.4.1" }] alliander-gazebo = [ { name = "numpy", specifier = ">=2.4.1" }, From b363ed12caec458fed2e824e08abc510bf65a9c9 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 10:59:25 +0200 Subject: [PATCH 079/119] Fix linting Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_husarion/launch/controllers.launch.py | 2 +- .../src/alliander_tests/tests/test_nav2_navigation_gps.py | 5 ++++- .../src/alliander_tests/tests/test_nav2_navigation_lidar.py | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index 08c4b5b6d..fbe2b37f8 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -43,7 +43,7 @@ def launch_setup(context: LaunchContext) -> list: "topic": "cmd_vel_joy", "timeout": 0.5, "priority": 100, - } + }, } }, ], diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index 017acf40c..b55413492 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -100,7 +100,10 @@ def callback(msg: NavSatFix) -> None: ) -for vehicle in ["panther", "lynx",]: +for vehicle in [ + "panther", + "lynx", +]: for lidar in ["velodyne", "ouster"]: for gps in ["gps"]: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index a155adf9e..54a36cf4e 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -106,7 +106,10 @@ def test_goal_pose_lidar( ) -for vehicle in ["panther", "lynx",]: +for vehicle in [ + "panther", + "lynx", +]: for lidar in ["velodyne", "ouster"]: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) From 92cad2c2e13426ed689d5ea29f9e9bac6b242265 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 12:36:23 +0200 Subject: [PATCH 080/119] Fix bug that appeared for collision monitor, and change /cmd_vel to /cmd_vel_final to cause no confusion Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 2 +- .../husarion/urdf/original/gazebo.urdf.xacro | 2 +- .../launch/controllers.launch.py | 4 ++-- .../src/alliander_nav2/launch/nav2.launch.py | 20 ++++++++----------- .../tests/test_collision_monitor.py | 2 +- .../tests/test_nav2_navigation_gps.py | 5 +---- .../tests/test_nav2_navigation_lidar.py | 5 +---- .../src/alliander_tests/tests/test_vehicle.py | 2 +- 8 files changed, 16 insertions(+), 26 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d88c447e1..ef2f980ff 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -205,4 +205,4 @@ jobs: - name: Install dependencies run: pip install pyyaml mashumaro - name: Run tests in alliander_tests container - run: python3 start.py --pytest-no-nvidia --mode all -k \"nav2_navigation_gps or nav2_navigation_lidar\" + run: python3 start.py --pytest-no-nvidia --mode all -k \"collision_monitor or nav2_navigation_gps or nav2_navigation_lidar\" diff --git a/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro b/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro index ab3ec829f..7e24b353e 100644 --- a/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro +++ b/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro @@ -37,7 +37,7 @@ ${config_file} ${namespace} - drive_controller/cmd_vel:=cmd_vel_test + drive_controller/cmd_vel:=cmd_vel_final drive_controller/odom:=odometry/wheels drive_controller/transition_event:=drive_controller/_transition_event imu_broadcaster/imu:=imu/data diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index fbe2b37f8..8a760a7a4 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -47,7 +47,7 @@ def launch_setup(context: LaunchContext) -> list: } }, ], - remappings=[("cmd_vel_out", "cmd_vel_test")], + remappings=[("cmd_vel_out", "cmd_vel_final")], ) joint_state_broadcaster_spawner = Node( @@ -94,7 +94,7 @@ def launch_setup(context: LaunchContext) -> list: "--controller-manager", "controller_manager", "--controller-ros-args", - "--remap drive_controller/cmd_vel:=cmd_vel_test", + "--remap drive_controller/cmd_vel:=cmd_vel_final", "--controller-ros-args", "--remap drive_controller/odom:=odometry/wheels", "--controller-ros-args", diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index df6ae3abe..fe458d9b4 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -186,7 +186,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 "base_frame_id": f"{namespace_vehicle}/base_footprint", "odom_frame_id": f"{namespace_vehicle}/odom", "cmd_vel_in_topic": f"/{namespace_vehicle}/cmd_vel_raw", - "cmd_vel_out_topic": f"/{namespace_vehicle}/cmd_vel", + "cmd_vel_out_topic": f"/{namespace_vehicle}/cmd_vel_nav", "scan": { "topic": f"/{namespace_lidar}/scan", }, @@ -253,9 +253,6 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 follow_path_params.file, ], namespace=namespace_vehicle, - remappings=[ - (f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_nav") - ], ) all_lifecycle_nodes["planner_server"] = LifecycleNode( @@ -275,9 +272,6 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 name="behavior_server", parameters=[behavior_server_params.file], namespace=namespace_vehicle, - remappings=[ - (f"/{namespace_vehicle}/cmd_vel", f"/{namespace_vehicle}/cmd_vel_nav") - ], ) all_lifecycle_nodes["bt_navigator"] = LifecycleNode( @@ -316,17 +310,19 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 remappings=remappings, ) + pub_topic = ( + f"/{namespace_vehicle}/cmd_vel_nav" + if not nav2.collision_monitor + else f"/{namespace_vehicle}/cmd_vel_raw" + ) + register_lifecycle_nodes = [] for node_name in lifecycle_nodes_names: register_lifecycle_nodes.append(all_lifecycle_nodes[node_name]) return [ SetParameter(name="use_sim_time", value=vehicle_config.simulation), - SetRemap( - src=f"/{namespace_vehicle}/cmd_vel", dst=f"/{namespace_vehicle}/cmd_vel_raw" - ) - if nav2.collision_monitor - else SKIP, + SetRemap(src=f"/{namespace_vehicle}/cmd_vel", dst=pub_topic), *[Register.on_start(node, context) for node in register_lifecycle_nodes], Register.on_log(lifecycle_manager, "Managed nodes are active", context), Register.on_log(nav2_manager, "Controller is ready.", context) diff --git a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py index 86b25b439..8320cce02 100644 --- a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py +++ b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py @@ -48,7 +48,7 @@ def callback_function_cmd_vel(msg: TwistStamped) -> None: test_node.create_subscription( msg_type=TwistStamped, - topic=f"/{self.platforms['vehicle'].namespace}/cmd_vel", + topic=f"/{self.platforms['vehicle'].namespace}/cmd_vel_final", callback=callback_function_cmd_vel, qos_profile=10, ) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py index b55413492..52f3b5b52 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_gps.py @@ -100,10 +100,7 @@ def callback(msg: NavSatFix) -> None: ) -for vehicle in [ - "panther", - "lynx", -]: +for vehicle in ["panther", "lynx"]: for lidar in ["velodyne", "ouster"]: for gps in ["gps"]: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) diff --git a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py index 54a36cf4e..158b59781 100644 --- a/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py +++ b/alliander_tests/src/alliander_tests/tests/test_nav2_navigation_lidar.py @@ -106,10 +106,7 @@ def test_goal_pose_lidar( ) -for vehicle in [ - "panther", - "lynx", -]: +for vehicle in ["panther", "lynx"]: for lidar in ["velodyne", "ouster"]: vehicle_platform = Vehicle(vehicle, (0, 0, 0.2)) lidar_platform = Lidar(lidar, (0.13, -0.13, 0.35)) diff --git a/alliander_tests/src/alliander_tests/tests/test_vehicle.py b/alliander_tests/src/alliander_tests/tests/test_vehicle.py index df288f1fb..1b9fe8e9b 100644 --- a/alliander_tests/src/alliander_tests/tests/test_vehicle.py +++ b/alliander_tests/src/alliander_tests/tests/test_vehicle.py @@ -72,7 +72,7 @@ def test_driving(self, test_node: Node, timeout: int) -> None: ) pub = test_node.create_publisher( - TwistStamped, f"/{self.platforms['vehicle'].namespace}/cmd_vel", 10 + TwistStamped, f"/{self.platforms['vehicle'].namespace}/cmd_vel_nav", 10 ) wait_for_subscriber(pub, timeout) From e2b4c635ac50281aeb6798eba407802ebdeb36f7 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 13:52:02 +0200 Subject: [PATCH 081/119] Try running now without the extreme timeout changes Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../husarion/config/lynx_controllers.yaml | 2 +- .../husarion/config/panther_controllers.yaml | 2 +- .../src/alliander_nav2/config/nav2/bt_navigator.yaml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/alliander_core/src/alliander_description/husarion/config/lynx_controllers.yaml b/alliander_core/src/alliander_description/husarion/config/lynx_controllers.yaml index f263d223c..530883b63 100644 --- a/alliander_core/src/alliander_description/husarion/config/lynx_controllers.yaml +++ b/alliander_core/src/alliander_description/husarion/config/lynx_controllers.yaml @@ -64,7 +64,7 @@ enable_odom_tf: true - cmd_vel_timeout: 10.0 + cmd_vel_timeout: 0.2 publish_limited_velocity: false # Velocity and acceleration limits diff --git a/alliander_core/src/alliander_description/husarion/config/panther_controllers.yaml b/alliander_core/src/alliander_description/husarion/config/panther_controllers.yaml index fbdd96ed2..9ba477466 100644 --- a/alliander_core/src/alliander_description/husarion/config/panther_controllers.yaml +++ b/alliander_core/src/alliander_description/husarion/config/panther_controllers.yaml @@ -65,7 +65,7 @@ enable_odom_tf: true - cmd_vel_timeout: 10.0 + cmd_vel_timeout: 0.5 #publish_limited_velocity: true use_stamped_vel: false diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml index 1b61d5703..153685dfd 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml @@ -9,9 +9,9 @@ bt_navigator: default_nav_to_pose_bt_xml: substitute_me robot_base_frame: substitute_me odom_topic: substitute_me - default_server_timeout: 10000 - default_cancel_timeout: 10000 - wait_for_service_timeout: 10000 + # default_server_timeout: 10000 + # default_cancel_timeout: 10000 + # wait_for_service_timeout: 10000 error_code_names: - compute_path_error_code - follow_path_error_code From 27eafb86d2e4c34aff9fd4305e5ac240ed57eded Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 14:13:22 +0200 Subject: [PATCH 082/119] Add back the increased nav2 timeouts, since the action server for compute_path_to_pose would otherwise time out Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_nav2/config/nav2/bt_navigator.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml index 153685dfd..1b61d5703 100644 --- a/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml +++ b/alliander_nav2/src/alliander_nav2/config/nav2/bt_navigator.yaml @@ -9,9 +9,9 @@ bt_navigator: default_nav_to_pose_bt_xml: substitute_me robot_base_frame: substitute_me odom_topic: substitute_me - # default_server_timeout: 10000 - # default_cancel_timeout: 10000 - # wait_for_service_timeout: 10000 + default_server_timeout: 10000 + default_cancel_timeout: 10000 + wait_for_service_timeout: 10000 error_code_names: - compute_path_error_code - follow_path_error_code From f22d145c413447938a78e0501032b6b5887e4bc6 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 14:54:33 +0200 Subject: [PATCH 083/119] Move twist_mux to husarion.launch.py and add use_sim_time to its parameters Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../launch/controllers.launch.py | 26 ----------------- .../launch/husarion.launch.py | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index 8a760a7a4..8bdebb177 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -25,31 +25,6 @@ def launch_setup(context: LaunchContext) -> list: """ vehicle_config = Vehicle.from_str(platform_arg.string_value(context)) - twist_mux = Node( - package="twist_mux", - executable="twist_mux", - name="twist_mux", - namespace=vehicle_config.namespace, - parameters=[ - {"use_stamped": True}, - { - "topics": { - "navigation": { - "topic": "cmd_vel_nav", - "timeout": 0.5, - "priority": 10, - }, - "joystick": { - "topic": "cmd_vel_joy", - "timeout": 0.5, - "priority": 100, - }, - } - }, - ], - remappings=[("cmd_vel_out", "cmd_vel_final")], - ) - joint_state_broadcaster_spawner = Node( package="controller_manager", executable="spawner", @@ -105,7 +80,6 @@ def launch_setup(context: LaunchContext) -> list: ) return [ - Register.on_start(twist_mux, context), Register.on_exit(joint_state_broadcaster_spawner, context), Register.on_exit(imu_broadcaster_spawner, context), Register.on_exit(drive_controller_spawner, context), diff --git a/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py b/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py index 9ae03dce3..36951af6d 100644 --- a/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py @@ -9,6 +9,7 @@ from alliander_utilities.ros_utils import get_file_path from launch import LaunchContext, LaunchDescription from launch.actions import ExecuteProcess, OpaqueFunction +from launch_ros.actions import Node platform_arg = LaunchArgument("platform_config", "") @@ -47,6 +48,32 @@ def launch_setup(context: LaunchContext) -> list: orientation=vehicle_config.orientation, ) + twist_mux = Node( + package="twist_mux", + executable="twist_mux", + name="twist_mux", + namespace=vehicle_config.namespace, + parameters=[ + {"use_sim_time": vehicle_config.simulation}, + {"use_stamped": True}, + { + "topics": { + "navigation": { + "topic": "cmd_vel_nav", + "timeout": 0.5, + "priority": 10, + }, + "joystick": { + "topic": "cmd_vel_joy", + "timeout": 0.5, + "priority": 100, + }, + } + }, + ], + remappings=[("cmd_vel_out", "cmd_vel_final")], + ) + controllers = RegisteredLaunchDescription( get_file_path("alliander_husarion", ["launch"], "controllers.launch.py") ) @@ -61,6 +88,7 @@ def launch_setup(context: LaunchContext) -> list: if vehicle_config.simulation else SKIP, Register.on_start(static_tf, context) if not vehicle_config.nav2 else SKIP, + Register.on_start(twist_mux, context), Register.group(controllers, context) if vehicle_config.simulation else SKIP, Register.on_start(sleep_infinity, context), ] From fa425dde50ffca2122c0409d636f3c1536b9c5c6 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 15:20:10 +0200 Subject: [PATCH 084/119] Move use_sim_time parameter Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../src/alliander_husarion/launch/husarion.launch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py b/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py index 36951af6d..add35ef27 100644 --- a/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py @@ -9,7 +9,7 @@ from alliander_utilities.ros_utils import get_file_path from launch import LaunchContext, LaunchDescription from launch.actions import ExecuteProcess, OpaqueFunction -from launch_ros.actions import Node +from launch_ros.actions import Node, SetParameter platform_arg = LaunchArgument("platform_config", "") @@ -54,7 +54,6 @@ def launch_setup(context: LaunchContext) -> list: name="twist_mux", namespace=vehicle_config.namespace, parameters=[ - {"use_sim_time": vehicle_config.simulation}, {"use_stamped": True}, { "topics": { @@ -84,6 +83,7 @@ def launch_setup(context: LaunchContext) -> list: sleep_infinity = ExecuteProcess(cmd=["sleep", "infinity"]) return [ + SetParameter(name="use_sim_time", value=vehicle_config.simulation), Register.on_start(state_publisher, context) if vehicle_config.simulation else SKIP, From d006593d010fcb02296ccd37ec8e52b69c0613e9 Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 15:36:10 +0200 Subject: [PATCH 085/119] Change cmd_vel_final back to cmd_vel since Husarion hardware uses cmd_vel by default Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../husarion/urdf/original/gazebo.urdf.xacro | 2 +- .../src/alliander_husarion/launch/controllers.launch.py | 2 +- .../src/alliander_husarion/launch/husarion.launch.py | 2 +- .../src/alliander_tests/tests/test_collision_monitor.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro b/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro index 7e24b353e..e451f3167 100644 --- a/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro +++ b/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro @@ -37,7 +37,7 @@ ${config_file} ${namespace} - drive_controller/cmd_vel:=cmd_vel_final + drive_controller/cmd_vel:=cmd_vel drive_controller/odom:=odometry/wheels drive_controller/transition_event:=drive_controller/_transition_event imu_broadcaster/imu:=imu/data diff --git a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py index 8bdebb177..78c97ae4f 100644 --- a/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/controllers.launch.py @@ -69,7 +69,7 @@ def launch_setup(context: LaunchContext) -> list: "--controller-manager", "controller_manager", "--controller-ros-args", - "--remap drive_controller/cmd_vel:=cmd_vel_final", + "--remap drive_controller/cmd_vel:=cmd_vel", "--controller-ros-args", "--remap drive_controller/odom:=odometry/wheels", "--controller-ros-args", diff --git a/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py b/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py index add35ef27..9a1b2ee84 100644 --- a/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py +++ b/alliander_husarion/src/alliander_husarion/launch/husarion.launch.py @@ -70,7 +70,7 @@ def launch_setup(context: LaunchContext) -> list: } }, ], - remappings=[("cmd_vel_out", "cmd_vel_final")], + remappings=[("cmd_vel_out", "cmd_vel")], ) controllers = RegisteredLaunchDescription( diff --git a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py index 8320cce02..86b25b439 100644 --- a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py +++ b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py @@ -48,7 +48,7 @@ def callback_function_cmd_vel(msg: TwistStamped) -> None: test_node.create_subscription( msg_type=TwistStamped, - topic=f"/{self.platforms['vehicle'].namespace}/cmd_vel_final", + topic=f"/{self.platforms['vehicle'].namespace}/cmd_vel", callback=callback_function_cmd_vel, qos_profile=10, ) From 555bbe252f6db89608041bda9127c564000af04f Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 15:48:30 +0200 Subject: [PATCH 086/119] Add limit to the ROS domain ID number Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- conftest.py | 8 +++++++- start.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/conftest.py b/conftest.py index 8b8576c1d..6e80939cf 100644 --- a/conftest.py +++ b/conftest.py @@ -27,6 +27,9 @@ LAUNCH_TIMEOUT = 300 # seconds COMPOSE_FILE = "/alliander_robotics/compose_pytest.yml" HOST_COMPOSE_FILE = "/alliander_robotics/compose.yml" +MAX_ROS_DOMAIN_ID = ( + 232 # https://docs.ros.org/en/jazzy/Concepts/Intermediate/About-Domain-ID.html +) class Configurations: @@ -98,7 +101,10 @@ def control_class(request: SubRequest) -> Generator: Yields: Generator: Starts and stops Docker containers for each module. """ - Configurations.ros_domain_id += 1 + Configurations.ros_domain_id = (Configurations.ros_domain_id + 1) % ( + MAX_ROS_DOMAIN_ID + 1 + ) # Loop back to ID 0 if new ID exceeds the allowed maximum + os.environ["ROS_DOMAIN_ID"] = f"{Configurations.ros_domain_id}" print("") cprint(f"[{request.cls.__name__}]: started", "blue") diff --git a/start.py b/start.py index f45cca0d6..9e4c2b8d3 100755 --- a/start.py +++ b/start.py @@ -64,7 +64,7 @@ def __init__(self, ros_domain_id: int = 0) -> None: """Initialize. Args: - ros_domain_id (int): optionally provide ros domain id. + ros_domain_id (int): optionally provide ROS domain ID. """ self.mode: MODE | None = None self.remove_nvidia = False From 1967ecb7d563a7ecb29e3ec19470eb7ae53654fc Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 15:48:55 +0200 Subject: [PATCH 087/119] Revert 300s launch timeout back to 90s Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index 6e80939cf..3288b6a79 100644 --- a/conftest.py +++ b/conftest.py @@ -24,7 +24,7 @@ from predefined_configurations import PredefinedConfigurations from start import Compose -LAUNCH_TIMEOUT = 300 # seconds +LAUNCH_TIMEOUT = 90 # seconds COMPOSE_FILE = "/alliander_robotics/compose_pytest.yml" HOST_COMPOSE_FILE = "/alliander_robotics/compose.yml" MAX_ROS_DOMAIN_ID = ( From 92f6069b6f5b40be4a4bcb81810493657801be3f Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 16:13:56 +0200 Subject: [PATCH 088/119] Revert the pytest line in the pr.yml file and add final ros domain docstrings Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 2 +- conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ef2f980ff..1a68a0fe4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -205,4 +205,4 @@ jobs: - name: Install dependencies run: pip install pyyaml mashumaro - name: Run tests in alliander_tests container - run: python3 start.py --pytest-no-nvidia --mode all -k \"collision_monitor or nav2_navigation_gps or nav2_navigation_lidar\" + run: python3 start.py --pytest-no-nvidia diff --git a/conftest.py b/conftest.py index 3288b6a79..7714ad560 100644 --- a/conftest.py +++ b/conftest.py @@ -38,7 +38,7 @@ class Configurations: Attributes: mode (str): The mode of testing. changed_packages (set[str]): The set of packages that have changed. - ros_domain_id (int): ... + ros_domain_id (int): ROS domain ID in which the test will be executed. """ mode: str From a0aa6369601830dfc9887d623df57381398bd23f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:23:13 +0000 Subject: [PATCH 089/119] Bump nicegui from 3.9.0 to 3.10.0 Bumps [nicegui](https://github.com/zauberzeug/nicegui) from 3.9.0 to 3.10.0. - [Release notes](https://github.com/zauberzeug/nicegui/releases) - [Changelog](https://github.com/zauberzeug/nicegui/blob/main/release.dockerfile) - [Commits](https://github.com/zauberzeug/nicegui/compare/v3.9.0...v3.10.0) --- updated-dependencies: - dependency-name: nicegui dependency-version: 3.10.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Signed-off-by: Peter Geurts --- uv.lock | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/uv.lock b/uv.lock index 1893cfb35..a33bf3291 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -33,25 +33,25 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, ] [[package]] @@ -731,7 +731,7 @@ wheels = [ [[package]] name = "nicegui" -version = "3.9.0" +version = "3.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -757,9 +757,9 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, { name = "watchfiles" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/38/ed046018db555c34ebc17738284d2f85bf9a544734cd44a87311128619a5/nicegui-3.9.0.tar.gz", hash = "sha256:7ae9046b321d029c438f7cd54a697838ed1962cecb92c622912283c66c8bf8f6", size = 19031869, upload-time = "2026-03-19T09:51:52.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/df/736d1e17db943d2a8425de22a69284b0374b8ed17efdcde10a5ba6d181f8/nicegui-3.10.0.tar.gz", hash = "sha256:10bca0ed1957c91506e54e02a8d2ad8860777e121fb54bfe59797493ba87ec14", size = 19147081, upload-time = "2026-04-07T09:27:33.09Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/11/f7f911f284ceb1b038c26d6f4833bc86d6583d5280156274fdb79be7dcfe/nicegui-3.9.0-py3-none-any.whl", hash = "sha256:4adfdb87a55e30b7fef05ab782efc030534ae6ad9afa330db856dfbb258e23c9", size = 19613351, upload-time = "2026-03-19T09:51:48.769Z" }, + { url = "https://files.pythonhosted.org/packages/de/44/73cd2dda7bd46a903751ea3fd93624ac3a97afdfb6a02e0aead6ee4cb0d1/nicegui-3.10.0-py3-none-any.whl", hash = "sha256:0c7f084b1c9036e645ce43727ac354d89214d355dededb25adc3ba2f4d08aaa3", size = 19711706, upload-time = "2026-04-07T09:27:29.203Z" }, ] [[package]] From 1a61302a766f5b4604b889dc726286667e4a0249 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 2 Apr 2026 15:28:07 +0200 Subject: [PATCH 090/119] added cppcheck Signed-off-by: Peter Geurts --- .cppcheck-suppressions | 5 ++ .pre-commit-config.yaml | 7 +++ .../src/fr3_gripper_simulation.cpp | 46 ++++++++----------- .../src/diagnostics_health_check.cpp | 6 +-- docs/content/workflows.md | 5 +- run_cppcheck.sh | 11 +++++ 6 files changed, 47 insertions(+), 33 deletions(-) create mode 100644 .cppcheck-suppressions create mode 100755 run_cppcheck.sh diff --git a/.cppcheck-suppressions b/.cppcheck-suppressions new file mode 100644 index 000000000..aa0d73607 --- /dev/null +++ b/.cppcheck-suppressions @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +unusedFunction:*/tests/*.cpp +unusedFunction:*/test/*.cpp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6ddb91ea7..502b243e1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,6 +50,13 @@ repos: hooks: - id: clang-format types_or: [c++, c, cuda] + - repo: local + hooks: + - id: cppcheck + name: cppcheck + entry: ./run_cppcheck.sh + language: system + pass_filenames: false - repo: local hooks: - id: doxygen diff --git a/alliander_franka/src/alliander_franka/src/fr3_gripper_simulation.cpp b/alliander_franka/src/alliander_franka/src/fr3_gripper_simulation.cpp index 49a6e7833..a22791084 100644 --- a/alliander_franka/src/alliander_franka/src/fr3_gripper_simulation.cpp +++ b/alliander_franka/src/alliander_franka/src/fr3_gripper_simulation.cpp @@ -6,7 +6,6 @@ Fr3Gripper::Fr3Gripper() : Node("fr3_gripper") { std::string ns = this->get_namespace(); - std::string node_name = this->get_name(); client = rclcpp_action::create_client( this, ns + "/gripper_action_controller/gripper_cmd"); @@ -63,17 +62,14 @@ void Fr3Gripper::execute( goal.command.position = {respect_limits(goal_handle->get_goal()->width)}; goal.command.effort = {goal_handle->get_goal()->force}; - send_goal(goal); + bool goal_result = send_goal(goal); auto result = std::make_shared(); - auto success = true; - if (rclcpp::ok()) { - result->success = success; - if (success) { - goal_handle->succeed(result); - } else { - goal_handle->abort(result); - } + result->success = goal_result; + if (goal_result) { + goal_handle->succeed(result); + } else { + goal_handle->abort(result); } }; @@ -89,17 +85,14 @@ void Fr3Gripper::execute( auto goal = ParallelGripperCommand::Goal(); goal.command.position = {upper_limit}; - send_goal(goal); + bool goal_result = send_goal(goal); auto result = std::make_shared(); - auto success = true; - if (rclcpp::ok()) { - result->success = success; - if (success) { - goal_handle->succeed(result); - } else { - goal_handle->abort(result); - } + result->success = goal_result; + if (goal_result) { + goal_handle->succeed(result); + } else { + goal_handle->abort(result); } }; @@ -114,17 +107,14 @@ void Fr3Gripper::execute( auto goal = ParallelGripperCommand::Goal(); goal.command.position = {respect_limits(goal_handle->get_goal()->width)}; - send_goal(goal); + bool goal_result = send_goal(goal); auto result = std::make_shared(); - auto success = true; - if (rclcpp::ok()) { - result->success = success; - if (success) { - goal_handle->succeed(result); - } else { - goal_handle->abort(result); - } + result->success = goal_result; + if (goal_result) { + goal_handle->succeed(result); + } else { + goal_handle->abort(result); } }; diff --git a/alliander_nav2/src/alliander_nav2/src/diagnostics_health_check.cpp b/alliander_nav2/src/alliander_nav2/src/diagnostics_health_check.cpp index 0c3e1bdc6..25b9c4fdd 100644 --- a/alliander_nav2/src/alliander_nav2/src/diagnostics_health_check.cpp +++ b/alliander_nav2/src/alliander_nav2/src/diagnostics_health_check.cpp @@ -27,10 +27,8 @@ class IsSystemHealthy : public BT::ConditionNode { */ IsSystemHealthy(const std::string& name, const BT::NodeConfiguration& config) : BT::ConditionNode(name, config), - gps_status_(diagnostic_msgs::msg::DiagnosticStatus::OK) { - // Get the shared Nav2 node from blackboard - node_ = config.blackboard->get("node"); - + gps_status_(diagnostic_msgs::msg::DiagnosticStatus::OK), + node_(config.blackboard->get("node")) { // Create a dedicated callback group callback_group_ = node_->create_callback_group( rclcpp::CallbackGroupType::MutuallyExclusive, false); diff --git a/docs/content/workflows.md b/docs/content/workflows.md index f6a80eab6..7e763ee0e 100644 --- a/docs/content/workflows.md +++ b/docs/content/workflows.md @@ -19,7 +19,10 @@ Lint all Python files according to the rules in `pyproject.toml`. See the full l Checks the docstring of the .py files using the rules specified in *pyproject.toml*. These checks might be implemented in Ruff in the [future](https://github.com/astral-sh/ruff/issues/12434), but for now we use pydoclint for the additional checks not available in Ruff. - **clang-format**:\ -Checks the format of the .cpp, .h and .hpp files in this repository. +Checks the formatting of the .cpp, .h and .hpp files in this repository. + +- **cppformat**:\ +Static code checker for .cpp files in this repository. - **reuse**:\ Checks all files in this repository on usage of copyright terms. diff --git a/run_cppcheck.sh b/run_cppcheck.sh new file mode 100755 index 000000000..fdf24cd90 --- /dev/null +++ b/run_cppcheck.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +set -e +cppcheck \ + --suppressions-list=.cppcheck-suppressions \ + --enable=warning,performance,portability,style \ + --error-exitcode=1 \ + $(find . -name *.cpp) + From 2fd0e65e21bccebbfe7aea460053d14b289974ec Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 2 Apr 2026 16:58:58 +0200 Subject: [PATCH 091/119] added clang-tidy to build process Signed-off-by: Peter Geurts --- alliander_core/alliander_core.Dockerfile | 1 + common/colcon_build.sh | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/alliander_core/alliander_core.Dockerfile b/alliander_core/alliander_core.Dockerfile index cf775df33..ec376c526 100644 --- a/alliander_core/alliander_core.Dockerfile +++ b/alliander_core/alliander_core.Dockerfile @@ -12,6 +12,7 @@ ENV WORKDIR=alliander RUN apt update && apt install -y -qq --no-install-recommends \ bash \ build-essential \ + clang-tidy \ curl \ flake8 \ git \ diff --git a/common/colcon_build.sh b/common/colcon_build.sh index 236a82495..17a638961 100755 --- a/common/colcon_build.sh +++ b/common/colcon_build.sh @@ -10,4 +10,16 @@ set -e colcon build --symlink-install \ --cmake-args -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=1 -echo "source $(pwd)/install/setup.bash" >> /root/.bashrc \ No newline at end of file + +# run clang-tidy checks, which will also produce warnings in /opt/ros/jazzy/src/gtest_vendor/... +echo "Running clang-tidy" +clang_tidy_output=$(run-clang-tidy -p=build/ -header-filter=src/ src/alliander_* 2>&1) || true +echo "$clang_tidy_output" + +# only exit with failure code if our own code has warnings +if echo "$clang_tidy_output" | grep -qP "src/.*warning:"; then + echo "clang-tidy found warnings, failing..." + exit 1 +fi + +echo "source $(pwd)/install/setup.bash" >> /root/.bashrc From 4ac8f660cc8c639d06bc4c4d4cc581e853ea930b Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Mon, 13 Apr 2026 10:47:23 +0200 Subject: [PATCH 092/119] linting Signed-off-by: Peter Geurts --- alliander_xsens/src/alliander_xsens/launch/xsens.launch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index 3038a473c..43af1d687 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: Apache-2.0 -import sys import time from alliander_utilities.config_objects import Imu From 774ddef3c2b51acc99082acb685e3169e0e71c27 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Wed, 22 Apr 2026 10:12:05 +0200 Subject: [PATCH 093/119] start hardware after madgwick filter so that device is discoverable on first try Signed-off-by: Peter Geurts --- .../src/alliander_xsens/launch/xsens.launch.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index 43af1d687..ff7277205 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -93,9 +93,17 @@ def launch_setup(context: LaunchContext) -> list: return [ Register.on_start(state_publisher, context), Register.on_start(static_tf, context), - Register.on_start(hardware, context) if not imu_config.simulation else SKIP, Register.on_start(imu_bridge_node, context), - Register.on_start(madgwick_filter_node, context), + Register.on_start(madgwick_filter_node, context) + if not imu_config.simulation + else SKIP, + Register.on_log( + hardware, + "Still waiting for data on topics imu/data_raw and imu/mag...", + context, + ) + if not imu_config.simulation + else SKIP, ] From 5817c6ef0498dbba9574d7a6dae20530925ec51e Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Wed, 22 Apr 2026 10:13:01 +0200 Subject: [PATCH 094/119] docs Signed-off-by: Peter Geurts --- alliander_xsens/src/alliander_xsens/launch/xsens.launch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index ff7277205..30c34a83f 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -97,6 +97,8 @@ def launch_setup(context: LaunchContext) -> list: Register.on_start(madgwick_filter_node, context) if not imu_config.simulation else SKIP, + # there seems to be a delay before the IMU is available for + # xsens_mti_node, so wait until madgwick filter is started Register.on_log( hardware, "Still waiting for data on topics imu/data_raw and imu/mag...", From 175b963572e6e8c06016abb1bb134ab3f713f0e7 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Wed, 22 Apr 2026 12:43:19 +0200 Subject: [PATCH 095/119] updated panther_gps_navigation Signed-off-by: Peter Geurts --- .../alliander_utilities/config_objects.py | 6 +++--- .../alliander_visualization/tool_manager.py | 12 ++++++++++++ .../src/alliander_xsens/launch/xsens.launch.py | 4 ++-- predefined_configurations.py | 10 ++++++---- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py b/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py index 96a03bec5..3caf608c4 100644 --- a/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py +++ b/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py @@ -332,8 +332,8 @@ class GPS(Platform): @dataclass -class Imu(Platform): - """Configuration for an Imu platform. +class IMU(Platform): + """Configuration for an IMU platform. Attributes: platform_type (str): Type identifier for the platform. @@ -355,7 +355,7 @@ class PlatformList(Config): platforms: List[ Annotated[ - Union[Platform, Arm, Vehicle, Camera, GPS, Imu, Lidar], + Union[Platform, Arm, Vehicle, Camera, GPS, IMU, Lidar], Discriminator(field="platform_type", include_supertypes=True), ] ] = field(default_factory=list) diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py index ab3641c92..5fc9346ab 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py @@ -5,6 +5,7 @@ from alliander_utilities.config_objects import ( GPS, + IMU, Arm, Camera, Lidar, @@ -51,6 +52,8 @@ def __init__(self, config: VisualizationConfig, platform_list: PlatformList): self.add_depth_camera(Camera.from_str(platform.to_str())) case "GPS": self.add_gps(GPS.from_str(platform.to_str())) + case "IMU": + self.add_imu(IMU.from_str(platform.to_str())) case _: raise NotImplementedError( f"Configuration for platform {type(platform).__name__} is not implemented." @@ -180,3 +183,12 @@ def add_gps(platform: GPS) -> None: platform (GPS): The GPS platform configuration. """ Rviz.add_satellite(f"/{platform.namespace}/gps/fix") + + @staticmethod + def add_imu(_platform: IMU) -> None: + """Add IMU configurtions to Rviz and Vizanti. + + Args: + platform (IMU): The IMU platform configuration, + """ + pass diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index 30c34a83f..3926924b2 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -4,7 +4,7 @@ import time -from alliander_utilities.config_objects import Imu +from alliander_utilities.config_objects import IMU from alliander_utilities.launch_argument import LaunchArgument from alliander_utilities.launch_utils import SKIP, state_publisher_node, static_tf_node from alliander_utilities.register import Register @@ -26,7 +26,7 @@ def launch_setup(context: LaunchContext) -> list: Returns: list: The actions to start. """ - imu_config = Imu.from_str(platform_arg.string_value(context)) + imu_config = IMU.from_str(platform_arg.string_value(context)) vid = "2639" pid = "0301" diff --git a/predefined_configurations.py b/predefined_configurations.py index 796c4db72..5a62e5d82 100644 --- a/predefined_configurations.py +++ b/predefined_configurations.py @@ -10,9 +10,9 @@ from alliander_core.src.alliander_utilities.alliander_utilities.config_objects import ( GPS, + IMU, Arm, Camera, - Imu, Lidar, Platform, PlatformList, @@ -106,8 +106,8 @@ def config_realsense(self) -> None: # noqa: D102 self.plat_conf.platforms = [Camera("realsense", (0, 0, 0.5))] @register_configuration("xsens") - def config_xsense(self) -> None: # noqa: D102 - self.plat_conf.platforms = [Imu("xsens", (0, 0, 0.5))] + def config_xsens(self) -> None: # noqa: D102 + self.plat_conf.platforms = [IMU("xsens", (0, 0, 0.5))] @register_configuration("zed") def config_zed(self) -> None: # noqa: D102 @@ -247,12 +247,14 @@ def config_panther_gps_navigation(self) -> None: # noqa: D102 ip_address="10.15.20.5", ) gps = GPS("gps", position=(-0.08, -0.25, 0.2), orientation=(0, 0, -90)) + imu = IMU("xsens", position=(-0.23, -0.08, 0.18), orientation=(0, 0, -90)) camera = Camera("realsense", (0.18, 0, 0.2)) link(vehicle, lidar) link(vehicle, gps) + link(vehicle, imu) link(vehicle, camera) - self.plat_conf.platforms = [vehicle, lidar, gps, camera] + self.plat_conf.platforms = [vehicle, lidar, gps, imu, camera] self.viz_conf.gui = True self.sim_conf.world = "map_5.954036_51.977320" From db320224ffe5f55e7011f105c822c058547d8803 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Wed, 22 Apr 2026 17:17:03 +0200 Subject: [PATCH 096/119] symlink xsens device before starting Signed-off-by: Peter Geurts --- .../src/alliander_xsens/config/xsens_mti_node.yaml | 6 +++--- alliander_xsens/src/alliander_xsens/launch/xsens.launch.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml index 9fe2c053e..60f83db5e 100644 --- a/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml +++ b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml @@ -13,7 +13,7 @@ # Option 1: Automatic Device Scanning # If set to 'true', the driver will ignore 'port' and 'baudrate' settings. # It will automatically scan all ports and select the first device found. - scan_for_devices: true + scan_for_devices: false # Option 2: Manual Device Configuration # If you prefer to manually specify the device port and baudrate, @@ -27,8 +27,8 @@ # to set baudrate to 115200, send the command: FA FF 30 00 D1, # or to set it to 921600, send: FA FF 18 01 80 68. - # port: '/dev/ttyUSB0' # Uncomment and set your device's port. - # baudrate: 921600 # Uncomment and set your device's baudrate. + port: '/dev/xsens' # Uncomment and set your device's port. + baudrate: 115200 # Uncomment and set your device's baudrate. # Device Scanning Selection # ---------------- diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index 3926924b2..08dc0cf52 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 +import os import time from alliander_utilities.config_objects import IMU @@ -35,6 +36,11 @@ def launch_setup(context: LaunchContext) -> list: while imu_device is None and not imu_config.simulation: for device in list_ports.grep(f"{vid}:{pid}"): print(f"Found IMU device {device}") + try: + os.unlink("/dev/xsens") + except FileNotFoundError: + pass + os.symlink(device.name, "/dev/xsens") imu_device = device.name if imu_device is None: print( From 6c7c2a4812cf0e2da5bfb4c7a1f6c292124348f2 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Wed, 22 Apr 2026 17:21:56 +0200 Subject: [PATCH 097/119] better this way Signed-off-by: Peter Geurts --- .../src/alliander_xsens/launch/xsens.launch.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index 08dc0cf52..da4ec8231 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -33,13 +33,14 @@ def launch_setup(context: LaunchContext) -> list: pid = "0301" imu_device = None + try: + os.unlink("/dev/xsens") + except FileNotFoundError: + pass + while imu_device is None and not imu_config.simulation: for device in list_ports.grep(f"{vid}:{pid}"): print(f"Found IMU device {device}") - try: - os.unlink("/dev/xsens") - except FileNotFoundError: - pass os.symlink(device.name, "/dev/xsens") imu_device = device.name if imu_device is None: From 0cd07ab2646a3166e1b4a7c50a6e527a67eb036c Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 23 Apr 2026 13:00:09 +0200 Subject: [PATCH 098/119] added magnetic bias estimation script Signed-off-by: Peter Geurts --- .../config/xsens_mti_node.yaml | 6 +- .../alliander_xsens/launch/xsens.launch.py | 40 ++--- .../scripts/estimate_magnetometer_bias.py | 150 ++++++++++++++++++ 3 files changed, 163 insertions(+), 33 deletions(-) create mode 100755 alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py diff --git a/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml index 60f83db5e..2b1c121a9 100644 --- a/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml +++ b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml @@ -13,7 +13,7 @@ # Option 1: Automatic Device Scanning # If set to 'true', the driver will ignore 'port' and 'baudrate' settings. # It will automatically scan all ports and select the first device found. - scan_for_devices: false + scan_for_devices: true # Option 2: Manual Device Configuration # If you prefer to manually specify the device port and baudrate, @@ -27,8 +27,8 @@ # to set baudrate to 115200, send the command: FA FF 30 00 D1, # or to set it to 921600, send: FA FF 18 01 80 68. - port: '/dev/xsens' # Uncomment and set your device's port. - baudrate: 115200 # Uncomment and set your device's baudrate. + # port: '/dev/xsens' # Uncomment and set your device's port. + # baudrate: 115200 # Uncomment and set your device's baudrate. # Device Scanning Selection # ---------------- diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index da4ec8231..b9701f809 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -2,9 +2,6 @@ # # SPDX-License-Identifier: Apache-2.0 -import os -import time - from alliander_utilities.config_objects import IMU from alliander_utilities.launch_argument import LaunchArgument from alliander_utilities.launch_utils import SKIP, state_publisher_node, static_tf_node @@ -13,7 +10,6 @@ from launch import LaunchContext, LaunchDescription from launch.actions import OpaqueFunction from launch_ros.actions import Node -from serial.tools import list_ports platform_arg = LaunchArgument("platform_config", "") @@ -29,26 +25,6 @@ def launch_setup(context: LaunchContext) -> list: """ imu_config = IMU.from_str(platform_arg.string_value(context)) - vid = "2639" - pid = "0301" - imu_device = None - - try: - os.unlink("/dev/xsens") - except FileNotFoundError: - pass - - while imu_device is None and not imu_config.simulation: - for device in list_ports.grep(f"{vid}:{pid}"): - print(f"Found IMU device {device}") - os.symlink(device.name, "/dev/xsens") - imu_device = device.name - if imu_device is None: - print( - f"No Xsens IMU device (VID:PID {vid}:{pid}) found yet, make sure one is connected." - ) - time.sleep(1.0) - state_publisher = state_publisher_node( namespace=imu_config.namespace, platform="xsens", @@ -71,6 +47,8 @@ def launch_setup(context: LaunchContext) -> list: package="xsens_mti_ros2_driver", executable="xsens_mti_node", parameters=[parameter_file], + respawn=True, + respawn_delay=2.0, remappings=[ ("/imu/acceleration", "imu/acceleration"), ("/imu/angular_velocity", "imu/angular_velocity"), @@ -100,15 +78,17 @@ def launch_setup(context: LaunchContext) -> list: return [ Register.on_start(state_publisher, context), Register.on_start(static_tf, context), - Register.on_start(imu_bridge_node, context), - Register.on_start(madgwick_filter_node, context) + Register.on_start(imu_bridge_node, context) + if not imu_config.simulation + else SKIP, + Register.on_start( + madgwick_filter_node, + context, + ) if not imu_config.simulation else SKIP, - # there seems to be a delay before the IMU is available for - # xsens_mti_node, so wait until madgwick filter is started - Register.on_log( + Register.on_start( hardware, - "Still waiting for data on topics imu/data_raw and imu/mag...", context, ) if not imu_config.simulation diff --git a/alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py b/alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py new file mode 100755 index 000000000..e388ec837 --- /dev/null +++ b/alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +"""Estimate magnetometer hard-iron bias from live ROS 2 data or a CSV file. + +Usage (live): + uv run estimate_magnetometer_bias.py + +Usage (from CSV): + uv run estimate_magnetometer_bias.py /path/to/data.csv + +Bias is estimated as the midpoint of the measurement range on each axis, +which approximates the hard-iron offset under the assumption that the sensor +was rotated through all orientations during the calibration sweep. +""" + +import sys +import time + +import rclpy +from rclpy.node import Node +from sensor_msgs.msg import MagneticField + +try: + import matplotlib.pyplot as plt + + HAS_MATPLOTLIB = True +except ModuleNotFoundError: + print("matplotlib not found. Data will not be plotted.") + HAS_MATPLOTLIB = False + +MAG_TOPIC = "/xsens/imu/mag" +MAG_TIMEOUT = 2.0 # seconds of silence before bias is estimated + + +class MagneticBiasEstimator(Node): + """ROS 2 node that estimates the hard-iron magnetometer bias. + + Subscribes to magnetometer messages and accumulates samples. Once the + stream has been silent for `MAG_TIMEOUT` seconds the node computes the + midpoint bias and optionally renders a 3-D scatter plot of the collected + measurements. + + Args: + csv_file: Optional path to a CSV file with pre-recorded measurements. + When provided the node estimates the bias immediately from the file + rather than waiting for live messages. + """ + + def __init__(self, csv_file: str = "") -> None: + super().__init__("magnetic_bias_estimator") + self.sub_mag = self.create_subscription( + MagneticField, MAG_TOPIC, self.mag_callback, 1 + ) + self.timer = self.create_timer(MAG_TIMEOUT, self.on_timer) + self.stamp_mag_received = 0.0 + self.num_mag_received = 0 + + self.mag_x: list[float] = [] + self.mag_y: list[float] = [] + self.mag_z: list[float] = [] + self.magnetic_bias: tuple[float, float, float] = (0.0, 0.0, 0.0) + + if csv_file: + self.parse_csv(csv_file) + + def mag_callback(self, msg: MagneticField) -> None: + """Append an incoming magnetometer sample to the internal buffers.""" + self.get_logger().info("Received magnetic field message.", once=True) + self.num_mag_received += 1 + self.stamp_mag_received = time.time() + self.mag_x.append(msg.magnetic_field.x) + self.mag_y.append(msg.magnetic_field.y) + self.mag_z.append(msg.magnetic_field.z) + + def on_timer(self) -> None: + """Trigger bias estimation once the magnetometer stream goes silent.""" + if self.num_mag_received == 0: + self.get_logger().info("Waiting for imu/mag messages") + return + if time.time() - self.stamp_mag_received > MAG_TIMEOUT: + self.estimate_bias() + self.plot() + self.num_mag_received = 0 + + def estimate_bias(self) -> None: + """Estimate the hard-iron bias as the midpoint of each axis' range. + + Updates `self.magnetic_bias` with the ``(x, y, z)`` midpoints and + logs the result together with the sample count. + """ + mx = (max(self.mag_x) + min(self.mag_x)) / 2 + my = (max(self.mag_y) + min(self.mag_y)) / 2 + mz = (max(self.mag_z) + min(self.mag_z)) / 2 + self.get_logger().info( + f"Bias estimation with {self.num_mag_received} samples: " + f"mx={mx:.6f}, my={my:.6f}, mz={mz:.6f}" + ) + self.magnetic_bias = (mx, my, mz) + + def plot(self) -> None: + """Show a 3-D scatter plot of all collected samples and the estimated bias. + + Does nothing when matplotlib is not installed. + """ + if not HAS_MATPLOTLIB: + return + + ax = plt.figure().add_subplot(projection="3d") + ax.scatter(self.mag_x, self.mag_y, self.mag_z, label="samples") + ax.scatter(*self.magnetic_bias, color="#ff0000", marker="X", label="bias") + ax.set_xlabel("mag_x") + ax.set_ylabel("mag_y") + ax.set_zlabel("mag_z") + ax.set_title(f"Magnetic field measurements (n={self.num_mag_received})") + ax.legend() + ax.grid(True) + + plt.tight_layout() + plt.show() + + def parse_csv(self, csv_file: str) -> None: + """Load pre-recorded magnetometer data from a CSV file and estimate bias. + + The CSV must contain at least three columns (mag_x, mag_y, mag_z) with + a single header row that is skipped during loading. + + Args: + csv_file: Path to the comma-separated data file. + """ + import numpy as np + + data = np.loadtxt(csv_file, delimiter=",", skiprows=1) + self.mag_x, self.mag_y, self.mag_z = data[:, 0], data[:, 1], data[:, 2] + self.num_mag_received = len(self.mag_x) + + self.estimate_bias() + self.plot() + + +if __name__ == "__main__": + csv_file = sys.argv[1] if len(sys.argv) > 1 else "" + + rclpy.init(args=None) + node = MagneticBiasEstimator(csv_file) + rclpy.spin(node) + + rclpy.shutdown() From 6e9466d781afffd5e5c7fa3cde71cd94848320f3 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 23 Apr 2026 13:11:46 +0200 Subject: [PATCH 099/119] added IMU topic to Nav2Config Signed-off-by: Peter Geurts --- .../alliander_utilities/alliander_utilities/config_objects.py | 2 ++ alliander_nav2/src/alliander_nav2/launch/nav2.launch.py | 2 ++ predefined_configurations.py | 1 + 3 files changed, 5 insertions(+) diff --git a/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py b/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py index 3caf608c4..b5c717900 100644 --- a/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py +++ b/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py @@ -208,6 +208,7 @@ class Nav2Config(Config): navigation (bool): Whether to enable navigation. gps (bool): Whether to enable GPS integration. controller (Literal["dwb", "graceful_motion", "mppi", "pure_pursuit", "rotation_shim", "vector_pursuit"]): Navigation controller type to use. + imu_topic (str): Topic on which IMU data is expected (defaults to /{vehicle_namespace}/imu/data). map (Literal["simulation_map", "ipkw", "ipkw_buiten"]): Map to use for navigation. window_size (int): Window size parameter. """ @@ -224,6 +225,7 @@ class Nav2Config(Config): "rotation_shim", "vector_pursuit", ] = "vector_pursuit" + imu_topic: str = "" map: Literal["simulation_map", "ipkw", "ipkw_buiten"] = "simulation_map" window_size: int = 10 diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index fe458d9b4..0fc71f7e0 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -286,6 +286,8 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 if nav2.gps: remappings.append(("/gps/fix", f"/{namespace_gps}/fix")) remappings.append(("/fromLL", f"/{namespace_gps}/fromLL")) + if nav2.imu_topic != "": + remappings.append(("imu/data", nav2.imu_topic)) all_lifecycle_nodes["waypoint_follower"] = LifecycleNode( package="nav2_waypoint_follower", diff --git a/predefined_configurations.py b/predefined_configurations.py index 5a62e5d82..37f6d76ef 100644 --- a/predefined_configurations.py +++ b/predefined_configurations.py @@ -239,6 +239,7 @@ def config_panther_gps_navigation(self) -> None: # noqa: D102 vehicle.nav2_config.controller = "mppi" vehicle.nav2_config.navigation = True vehicle.nav2_config.gps = True + vehicle.nav2_config.imu_topic = "/xsens/imu/data" vehicle.nav2_config.window_size = 50 lidar = Lidar( "velodyne", From 1f8d66d6d59414e5eeb167d3b87a43e6006237e0 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 23 Apr 2026 14:25:57 +0200 Subject: [PATCH 100/119] moved ekf/navsat nodes to nav2 as they now need imu config Signed-off-by: Peter Geurts --- .../alliander_utilities/config_objects.py | 2 - alliander_gps/alliander_gps.Dockerfile | 1 - .../src/alliander_gps/CMakeLists.txt | 2 +- .../src/alliander_gps/launch/gps.launch.py | 46 ---------------- alliander_nav2/alliander_nav2.Dockerfile | 1 + .../config/nav2}/ekf_global.yaml | 0 .../config/nav2}/navsat_transform.yaml | 0 .../src/alliander_nav2/launch/nav2.launch.py | 54 ++++++++++++++++++- 8 files changed, 54 insertions(+), 52 deletions(-) rename {alliander_gps/src/alliander_gps/config => alliander_nav2/src/alliander_nav2/config/nav2}/ekf_global.yaml (100%) rename {alliander_gps/src/alliander_gps/config => alliander_nav2/src/alliander_nav2/config/nav2}/navsat_transform.yaml (100%) diff --git a/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py b/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py index b5c717900..3caf608c4 100644 --- a/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py +++ b/alliander_core/src/alliander_utilities/alliander_utilities/config_objects.py @@ -208,7 +208,6 @@ class Nav2Config(Config): navigation (bool): Whether to enable navigation. gps (bool): Whether to enable GPS integration. controller (Literal["dwb", "graceful_motion", "mppi", "pure_pursuit", "rotation_shim", "vector_pursuit"]): Navigation controller type to use. - imu_topic (str): Topic on which IMU data is expected (defaults to /{vehicle_namespace}/imu/data). map (Literal["simulation_map", "ipkw", "ipkw_buiten"]): Map to use for navigation. window_size (int): Window size parameter. """ @@ -225,7 +224,6 @@ class Nav2Config(Config): "rotation_shim", "vector_pursuit", ] = "vector_pursuit" - imu_topic: str = "" map: Literal["simulation_map", "ipkw", "ipkw_buiten"] = "simulation_map" window_size: int = 10 diff --git a/alliander_gps/alliander_gps.Dockerfile b/alliander_gps/alliander_gps.Dockerfile index dfbba64df..ccf2e4558 100644 --- a/alliander_gps/alliander_gps.Dockerfile +++ b/alliander_gps/alliander_gps.Dockerfile @@ -11,7 +11,6 @@ ENV ROS_DISTRO=jazzy RUN apt update && apt install -y --no-install-recommends \ ros-$ROS_DISTRO-husarion-components-description \ ros-$ROS_DISTRO-nmea-navsat-driver \ - ros-$ROS_DISTRO-robot-localization \ && rm -rf /var/lib/apt/lists/* \ && apt autoremove -y \ && apt clean diff --git a/alliander_gps/src/alliander_gps/CMakeLists.txt b/alliander_gps/src/alliander_gps/CMakeLists.txt index 142cbf15f..ab6492d52 100644 --- a/alliander_gps/src/alliander_gps/CMakeLists.txt +++ b/alliander_gps/src/alliander_gps/CMakeLists.txt @@ -11,7 +11,7 @@ find_package(ament_cmake_python REQUIRED) # Shared folders: install( - DIRECTORY config launch + DIRECTORY launch DESTINATION share/${PROJECT_NAME} ) diff --git a/alliander_gps/src/alliander_gps/launch/gps.launch.py b/alliander_gps/src/alliander_gps/launch/gps.launch.py index 552c5a742..149ae1294 100644 --- a/alliander_gps/src/alliander_gps/launch/gps.launch.py +++ b/alliander_gps/src/alliander_gps/launch/gps.launch.py @@ -50,57 +50,11 @@ def launch_setup(context: LaunchContext) -> list: {"platform_config": gps_config.to_str()}, ) - ekf_global_params = AdaptedYaml( - get_file_path("alliander_gps", ["config"], "ekf_global.yaml"), - { - "odom_frame": f"{gps_config.parent.namespace}/odom", - "base_link_frame": f"{gps_config.parent.namespace}/base_footprint", - "odom0": f"/{gps_config.parent.namespace}/odometry/wheels", - "odom1": f"/{gps_config.namespace}/odometry/gps", - "imu0": f"/{gps_config.parent.namespace}/imu/data", - }, - root_key=gps_config.parent.namespace, - ) - navsat_transform_params = AdaptedYaml( - get_file_path("alliander_gps", ["config"], "navsat_transform.yaml"), - {}, - root_key=gps_config.parent.namespace, - ) - - navsat_transform = Node( - package="robot_localization", - executable="navsat_transform_node", - name="navsat_transform", - namespace=gps_config.namespace, - parameters=[navsat_transform_params.file], - remappings=[ - ("imu", f"/{gps_config.parent.namespace}/imu/data"), - ( - "odometry/filtered", - f"/{gps_config.parent.namespace}/odometry/global", - ), - ], - ) - - # Define EKF node that creates the tf between odom and map: - ekf_global = Node( - package="robot_localization", - executable="ekf_node", - name="ekf_global", - namespace=gps_config.parent.namespace, - parameters=[ekf_global_params.file], - remappings=[ - ("odometry/filtered", f"/{gps_config.parent.namespace}/odometry/global"), - ], - ) - return [ SetParameter(name="use_sim_time", value=gps_config.simulation), Register.on_start(state_publisher, context), Register.on_start(static_tf, context), Register.group(hardware, context) if not gps_config.simulation else SKIP, - Register.on_start(navsat_transform, context) if parent.link else SKIP, - Register.on_start(ekf_global, context) if parent.link else SKIP, ] diff --git a/alliander_nav2/alliander_nav2.Dockerfile b/alliander_nav2/alliander_nav2.Dockerfile index 047275812..60d9777ba 100644 --- a/alliander_nav2/alliander_nav2.Dockerfile +++ b/alliander_nav2/alliander_nav2.Dockerfile @@ -11,6 +11,7 @@ ENV ROS_DISTRO=jazzy RUN apt update && apt install -y --no-install-recommends \ ros-$ROS_DISTRO-navigation2 \ ros-$ROS_DISTRO-nav2-bringup \ + ros-$ROS_DISTRO-robot-localization \ ros-$ROS_DISTRO-slam-toolbox \ && rm -rf /var/lib/apt/lists/* \ && apt autoremove -y \ diff --git a/alliander_gps/src/alliander_gps/config/ekf_global.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/ekf_global.yaml similarity index 100% rename from alliander_gps/src/alliander_gps/config/ekf_global.yaml rename to alliander_nav2/src/alliander_nav2/config/nav2/ekf_global.yaml diff --git a/alliander_gps/src/alliander_gps/config/navsat_transform.yaml b/alliander_nav2/src/alliander_nav2/config/nav2/navsat_transform.yaml similarity index 100% rename from alliander_gps/src/alliander_gps/config/navsat_transform.yaml rename to alliander_nav2/src/alliander_nav2/config/nav2/navsat_transform.yaml diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 0fc71f7e0..c19071779 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -34,11 +34,14 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 # Extract lidar and gps namespaces from childs. The first found will be used: namespace_gps = "" namespace_lidar = "" + namespace_imu = "" for child in vehicle_config.childs: if child.platform_type == "Lidar" and not namespace_lidar: namespace_lidar = child.namespace if child.platform_type == "GPS" and not namespace_gps: namespace_gps = child.namespace + if child.platform_type == "IMU" and not namespace_imu: + namespace_imu = child.namespace # Define configuration: lifecycle_nodes_names = [] @@ -95,6 +98,29 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 root_key=namespace_vehicle, ) + topic_imu = ( + f"/{namespace_vehicle}/imu/data" + if vehicle_config.simulation + else f"/{namespace_imu}/imu/data" + ) + ekf_global_params = AdaptedYaml( + get_file_path("alliander_nav2", ["config", "nav2"], "ekf_global.yaml"), + { + "odom_frame": f"{namespace_vehicle}/odom", + "base_link_frame": f"{namespace_vehicle}/base_footprint", + "odom0": f"/{namespace_vehicle}/odometry/wheels", + "odom1": f"/{namespace_gps}/odometry/gps", + "imu0": topic_imu, + }, + root_key=namespace_vehicle, + ) + + navsat_transform_params = AdaptedYaml( + get_file_path("alliander_nav2", ["config", "nav2"], "navsat_transform.yaml"), + {}, + root_key=namespace_gps, + ) + local_costmap_params = AdaptedYaml( get_file_path("alliander_nav2", ["config", "nav2"], "local_costmap.yaml"), { @@ -286,8 +312,6 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 if nav2.gps: remappings.append(("/gps/fix", f"/{namespace_gps}/fix")) remappings.append(("/fromLL", f"/{namespace_gps}/fromLL")) - if nav2.imu_topic != "": - remappings.append(("imu/data", nav2.imu_topic)) all_lifecycle_nodes["waypoint_follower"] = LifecycleNode( package="nav2_waypoint_follower", @@ -305,6 +329,30 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 namespace=namespace_vehicle, ) + # robot-localization nodes + ekf_global = Node( + package="robot_localization", + executable="ekf_node", + name="ekf_global", + namespace=vehicle_config.namespace, + parameters=[ekf_global_params.file], + remappings=[ + ("odometry/filtered", f"/{namespace_vehicle}/odometry/global"), + ], + ) + + navsat_transform = Node( + package="robot_localization", + executable="navsat_transform_node", + name="navsat_transform", + namespace=namespace_gps, + parameters=[navsat_transform_params.file], + remappings=[ + ("odometry/filtered", f"/{namespace_vehicle}/odometry/global"), + ("imu", topic_imu), + ], + ) + nav2_manager = Node( package="alliander_nav2", executable="nav2_manager.py", @@ -326,6 +374,8 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 SetParameter(name="use_sim_time", value=vehicle_config.simulation), SetRemap(src=f"/{namespace_vehicle}/cmd_vel", dst=pub_topic), *[Register.on_start(node, context) for node in register_lifecycle_nodes], + Register.on_start(ekf_global, context) if nav2.gps else SKIP, + Register.on_start(navsat_transform, context) if nav2.gps else SKIP, Register.on_log(lifecycle_manager, "Managed nodes are active", context), Register.on_log(nav2_manager, "Controller is ready.", context) if nav2.navigation From 38a8c931da909e4e4585f8977f6ac2618ff984f8 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Fri, 24 Apr 2026 13:29:02 +0200 Subject: [PATCH 101/119] fixed IMU orientation Signed-off-by: Peter Geurts --- .../xsens/meshes/MTi-6x0R.stl | Bin 199384 -> 199384 bytes .../src/alliander_nav2/launch/nav2.launch.py | 2 +- predefined_configurations.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alliander_core/src/alliander_description/xsens/meshes/MTi-6x0R.stl b/alliander_core/src/alliander_description/xsens/meshes/MTi-6x0R.stl index 5b08e0ed4a70db5e26c2bc88977697c4298000b8..df8d2ae91f93a1bd2bbb0bf3785f387876e66a52 100644 GIT binary patch literal 199384 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 literal 199384 zcmb51cU+EN`1p@a$S5j%R765U>Ur+_JSUZ8CD|)fDp@7kPb!2G(V$RdWh4sKbKj@i z-h1yoKK9=HPJKT7uH*Op{_*RdkJsn9-`92ax~_Ab!Cw8G+7K_-Hr;KltZl8jS#@jE zYhb^Do98msD)iP*)LqTiV(X<3HQi&^5LJm0M?gsk zoor$$FMbg#=X?*r?oS($inM=lYR?f+5<+9HZ?* z&?eKHNQ1|9#`segQmo4%M^~u{B%p4Dia&bOu;9mPix=-iNVHvURhcKF4XjImLOBE!bB!sN=^<>rEeqzJvVO)K{I^c6Yfu!SPD|9Nvd$vRuC9jL-E?XZ0KR9(6CQaodoDJ{$$ zk74VBa`~1RcjKZsZCtEcwZtF877ht02_gMU`Lg=Yhi@U2E z3k?lDCHTub(z>#&3LO>_C<&o+VbOR(aszSTdp*Z4rvLU-y-D*4B%maO(%xbE{%vb1 zvZ)2Va>0_+^T{P&W-cV1hPAA0i7o}Hq@(}8t#WqX`K%8XcnVZ_m8Y{1ENRfr=45bs zF6nt|8Am`#2%R>_l$Re&lj95hX~BeQMB6)u{4;7LX%Kh3vY!t)kj2pm`Cd1Y^ZRB= z$w5(6`{IHHP!dAbyaU8Q1AD113a6GkQZ*q zb9tNb430=#6;PR~(i_V;8lj?l4RPw!okHC0LG-<~O5>H9OB`;b5J*7X2&I~|!9Fea z3bm)YQdkG@t7$VLaS*sY3~w}P+1 zO}Fn?=C(-AB9KO?VH0Edk<+&_p+hv+N+7}4;P-1cWakU>#JESJIb!&hHkBjXw1!Iv zq!Ds?@LpbSq!B;ojN;lZB=~l9szgmq2hJ0Ebg<<52P9xGK&jGxCe0;~M(9++3;AY`rBb^F6DaI=P%fXU?A60)=pCv)cdHJCa$&#Yd+q4QTKYEY zmd;$5u7Yz0qbaDS-wa7Les&a{v$MBy^-B_Iq$}d&LP-drqsi)kIz6cV8ylKu?W}|yT0%O!+QAV}5<+xt zm@4At2$~vHlgkH8JCuabgBioi4rnIPob2o3xM`l1Eu+h(0*&d-HMstpbh5Sk4$Y5U z9?DSD3}QZdy9S}s6(NoEy+$LPnH5yew>T9Z@zrHHJRe zHI3AMKTvsZYec$VpGh7(#!9PQMx?P2MOGMjDTPVKtaRURoU6`Tct&04o|ZZenn<2k zbNhb?C<&psxJPBFBuh9-c;ZQ`iNx!@gYwBVWjPw zag{nCjgYba63K5$nzVE~Pm~Kjq>r(mQs8b(AdQf^=0LScheYXM0H3NV)20(YdtXI0 z(U?FQp%V`-s&|YYC$&h_a`gcTm_LM?{j*!xw`qtJmlH$bSFjHF8vNL_w=inJ3|-I` zEp6Lvn)3BmQ&LgqpI65#Q3g#(dA+7&ai3@<)5DqcxYmT#`GCTK0{O69diW!TBR1WO zQ$CNlt7&-Mn4=LIe6YK8Hf6ESW3QGb`vfX8_3BpYfCSWy(93ILSy#^x>H8v{(0}2l z44m>*6F<+GK$>+yZ4JS7$7tzwG*4uH9;DRw`KDP~VoV^7&=PGYp-~qTY0wHSE#2;| z_+D*NnJP%Y)FR~PK1JB3yQ6aq=LzrXeH7bE4M{~ygftu5m(LR1ofhbxYk9)|USB0} zPa86+w=sb?bY)l}HP+q$UsuBxpAx+_H5E4)l zLcP6X1*@(Wy48_d8l4oZ%p31aisv;Yunzb-uTyQ55E5gnb6%~bP%b2(B!qg=Hh5)- zfQ$CdlHo5k$9pSXb#~<017iXuu|AlxU6{FdytHIn4E3BcL216+m6#nhB9MTR5Zd%I zn(nRnO`2O!L)yD5mPo!s%TKMzB3_&0iM{9Q^2wtolBSd5h)ZN{dDq7)S=)_Ej-eax z`wCtw_DVi=DN%~5&XDsCiD2dGmgJWoukq3mA6Jj_5rV#2w==!J_TFiQGjW@~C`{4=X?$~DK zWeq0~NI=~Pb!?!e)|S82cK+))0_GgjY;0eurRVc)bSVx2l4{3Xf?DU5Z@%1uE34C6 z=90}VEXy5VR`hezLM?5%tcGy5c2f!d0(HRDA~a}JO>CPsf{xiJO0fOx={QG8n%|53 zdUZ*&{brQX<{wv5+xZfkaptJ%;w7O&>C;PYl6P9Ha_SNxUs^UHkbsg98rXdd-fV%e zVXG0+wG=O9Ps1Lh_n_@uE4gbuTFHOzK>q0$$MVsjI0n~}oTP!Zi#Y;*b=loZxiq{7 z>9wzb5ra>6QCHusrNcH05}bvvMGq*icR!VA-z+0=^?}g59IWmxX=z}(wFKu>NI*#l zg*}xfhQwB9Q)1#lLMA!Ces~pd?-gB=(-@R;lAd*K7i5PDd;x9_SlY5_kT|Bar5G z!1D`8KuMeq1c?<``c?}ef(2gW<&=zug${LP@QM~YzttBWIJuLysoO~EMS(zKe*c9eKB0ho-;%9iJFnmz+X|w`JADEE61b@` zxw2q8$qKAVpd^G$HhXoeAflO$j`O!_Y_@GD)2sc^Kmtl)tBj+AtSg9#j9|YgzqRjO z^9y(Gu$}0|v>;FtLaxo06d5og`QKFiAvW~tqwarqE7{@WOCXIJF{AoWs(EHH*;LxmrB$Ktq*L z-w3IBq*gJXa8k1{a0k&g(JCkRlJa&hb`ZlGvC6`5&vH6Ai`B=crCPkKyRq&{*FY)O zEkOzB{6o|CLw$1QRDz;@xTd^T_mS@3~|a_*$2}H%@8rd{cQ;oh$+gsGC`3-p1g=dE102 z^QzJ;{{$s-`=#<>cPEkFwc?eJ6KG44cil3pWvNfwSBW{e=;y2ENgy9CgCHM;@cpW!Y;Z#cMr&~HV zKz?5%miV9TNi3}ki2J2Eq+DI5dH;4Z*?TCK+-@7Mxi40%EMszO z&LOr#suC9=i=$afEYQ+h9U4fk{Tgt@it2GB>Z1WE-jqlnjgUc#mVWu#S@L+)K?<9Z zKw6eK5xw+FnoX_eG7C^MqMQ9l18Ib67Hg^I=v&>oy(Sz1zk;b{cD0X^^m++fSI-|P z4ZAXqB<;3SMrBPQtxp9KUH$ILk(iZaN~`I_E7D#$XVQh`{Cma?Rns#uG;!_$>DYO1 z;?>q)nUY{f;3-9r!3<(r!(X`(Z^wuMcb5s@uf@;?Q^Pp|o{>Nrp;5v2<%Zgcba0KQ z90AW^AkF5eCkFJ%0uP#g#6xPl#gmwSAEWr+vEfc`U~1V3WQXbWko!hCWc)J;o(Mtu zTh0J-D$i4SxYCy8WAe@bI?ZvvoY%jJ3{wILD2drQeWubU-S&!I^`}X`{=-PaR8a}; zXhPsuz9k-nc6O>PbvB0XYe!Edx!{#M7VBVrK$<5szLNZ7=oEbTpIH(-J%a?4gixom zMzV`hG9i5yUyXyYl987N-MBLg=3_ZRxue&Ukg&agKoH15=35RL|PdJYOw-dpuNv zeF73tH=6`sotK6?#o#9qBPCd(u%sc4P)OJ?G3r1#*6SH9tv@|V8UI72RGcRrdlI0` zeAiRypfM(lP=5DKc9L4s4hP4%;4=A|j-^-m-F_g=6V|R89GBP;&zsm!vOeUgba!^G zJPU;cl!TDal0H&B4=r8QdaUH`HeG4o)=zoe(1gSWPEZyM^H;1b?Z}42V5LXOB*oLe zqMs+#JgM$S|WusnV@Wtd@FT80!m`GqUNteFl7RkU2XK!UOs zFL76b9`;g>ZOJ825<+hc-0-e}t?}7HYmRX2FkBh7-A387GK)YOp#vS;;WOu)aem!h z6=VCK_5%s18=>4hJF&|ama=9lj@aJUSF!c!u1qLdNg&PEqTRO%z4pXl-@79vXb>bgU;^xA+)%G3S5EA0mEqoR}rT?Z=#fyU(J*$5@w$5o+e74{F? z`stEEqc`eFHtbBl-W27xr2!I95-Y18WzwWyOC$?dW;Xyt>DEvsvZJ@+${z);J=KjT z;OGNsgv`G$Rhzn}3%8tk0@gF65%OO@Uz&e*nlSlS42568TI1{d_7Xiwwb)13;)0e! z%i;9?xw7txCE56*h+GfRD}Ud*ISEZJX01dW?W7)OwpktUg4uDl>1(PqWhb1^iwGnv zu9=r#sh&peqTP(Bmj6r;UPcPa>1fU}I?HA(Iq++FqPT9SVgJr@IKv zPsebnf^wlg<_GYmmSkt)pf0|wr9JfrD6Z!P(yU1_fws-<9=(*7v#bfaT*Ojk5&5pn z_x&h!pU-^G;a8AmRx)L*Iy-lg&gUIZ6zca>8a*^3+nW^=NVAj8+Esm%eDDo5^x+oiDEfU zMHO{!Y`Q|1R`amV<}OcIL{w7@YkMiL{fapnq3QJ|h%M#Y!jBeV6s80cP&c!A)daI`%AUS0E*jfRdPR)QNs2hvcL(-@j4?2`Gu}&%M{H zH`b{y96GM0V}hD!W~{%3kI`Z-Rj||%In44rkRQ);OjRkTDf>cg)fjnGva7kdwNj?m0rOW_Ov2`Gu})xMt9*=+x=n)^gc z<9kg~jy?+_*@NRW+rC67#y{Lh*pWER+qqFn)A~Wgby7S#Thi;*NPUmm>-w`)IbQQq z7QAsIO-uy&YG$8J2{PL@0DxdSGnyH~~8>&@Fv)K4+Pa;gDG?C82&`7w4Au{#sM(FjFUwV>_?G46MLoM2d# zqU0{$KsK(}PapwxBXrg_Lw+`XCeFRxk~_161eC z-CDw9&s^nXa5lN?mal;X)XnS${8_yLXxEk`3bV#=&@c)kJc{VIvukc#xP5JxrH; z-$YQz0WMDnEnRq`%+CIl_-bGvhG~a+f;2)=x~Gn7H_yh}6>~&Lz_0l8_VSRms+C{d z@#*d2^vBRE; z_@6Jq0@U8-Rtl+7bxY+~0#l37g(5SooOi-6n|I|1Vwy!J2j*6eC6H#jyGvbgd7=ux zs^=uYlt2RNMku)V41D3|W>HwtLV!7k1eAnOWc47t?*0MsSDGM{&(0+_owJByy+s2F zD2aJ4xAnv;TQK?3%FghpPeNBjKBQ5`=p1Yc0Hl#`$J zX+__LBLh!-H+QBzPvw>!i818}n36r`S15H;(v=fA6>WFU#j|p@(Jx0FAHWgtE2s}4 z&+;f5*YiMWc6K`v%7rQ9Q`H)^k?wZ4lMW{{t0EY$U@zkPN8;s;sul4W4()$wG25k) z&FwZ-rWX=W659{n3RS0WNGVzUSz9r-6OZ7PO2ya?2`CAnV-2mO{7Z|424>7ItW71y zcCJ-KHsU~h?_3@oi#b}=k~t8+;6sZ#D~@uMAWRmOMTso^No zble(p;drj5`NUAtFLVt_HBQz9O`gtDRbiiR_+ZoSW$*O`!@g_C!V4xEx0~U_YWHfg z{7XMgVtO*U;J1d@BtOPGH-<2x2_r=B_#R(h^F;M0tC{c0XpLvJM2<#i_7mo35U1~W zG3vS+YCjdThOFt`T$ABGmB7^gt7Ag@4#n1Q^aV)36hivnd{~`(YWtDpqmJtu5+*Lz zeE1PTAOR))OU&$x?6xxR1W3SqK>A-|wDo4Y{!9lfQAj{Z|K`KUw)yTYtgIk0b>C`Y zQG2#VXP-nM%`7How0NxjX4U|L&1K>|u*_aM(}@v!qQWmZp{2(X1ixv-5P z)Zqvl*_O^OtykTGOBE!bB<8(SuEh@uj8)6$*l;tEmnul` zeb9f4TD+^6$-!1(G~|9XDQ!QE>`vXUfi~zV2C?KxVjr^q#~HTgobEP8H@a01x%IDjzF4?S8@F%r>1JT=AcM=r6_@H4QZ}?b?ZbR0d*sEaZWceDyWwFWXC9a z@=64;wYODfROw1aj)@^PvwJJ!Lc9KD4R_kqN**0PNIJSSik>+ZN0wVyDi0o6as-rw zkkg70^q@tec=Oy75vB_2fRdPh$@8^>(G5NM#~D87kbsidS@@`tg0W)@Id*LnZLubr z6xJTDwE57TOBJ8<^5nga=KK8R+5N(4o?}1K#dW?iWu{1=&78OD-}>BGiZ&Y}`#D5V zXaR==l!VZosg266CmYGeR?$@5*G;*Rkf?OgVFJH$Y@Mh~3EZw&3w_zR8`{=E7*RA{ zUNSA5z8o|_X<2)o64zDaY=XS)uFJj)(pcM2sb_~6y2&A0IdH~VQUA0eHw@+|VTD%8 zDN8HPZiLXJfQRDQuZhBgo<1A_Jx3so(3Xg8QsJ0da?kUX_3>wI2nnbgq33rWsOK-T zlh0p{tZ40jh>nKylt!gxdqjte?St+K2^*{hD{?5lt#3?E1eFGq46Jm$RXG1WSQ@N4J62W8r_kNOcaK%!^{=J5^l5XabZpx9K@`Tbz3T@4hfRYdz z|J_Ll9^)-fjO5!DB%maO78dvk({@jjtA&MeZ5I--g(9?jzytLcQ!9CH=O~WwsuQm~ zXqBUUZRbiL&CcPnY=zmqd&$G=MN-%gAOZUgvxZymEL!x$MqXJxiX$$!UZiBM-=KKR zb|a8x_fk_*bz_@Y%RBU=xPI>3b1t!4nXX{TiMTdQAntl=74LJd6|1G%pLIK1_LVyx zkDzb_fCTKp2o-)DuG&)%%THn>Il|U1mFz#1s;nPJ2&54*D_o{~yv#$k$%x>_D@Z`y z2yJh3vh3h#cX|5r2nxS~qa8oOr5q;0_jGqzSCVhCm*r(q6!ug|KuK(sk=0rE}P z+Rlv{NF&tp$PnFxPhI3qB#IjuAOUqVPmZGPMMM6}2OLXYc34Dee%wHc4zJZfn%O_D zTS#Xz7t79hp|r98Xr-UcGLrjtm!_gmgk4!nPVQO%*F8vSU2Ea;pw9A29&wIb>3j77>W zqf`>Ktxy9AsGIfk@ke}B2jLW+dBd-aZulsrE;C5_w*zcUJZ#Zi*iiOPy74ob zBcMJ=BV-w3COysXA`dl;q7P?kl}%|uq~*PR8c0Ch%pNt3sAtbJlK-)Y<_K4V3}y9| zbh68BJ4drV_;H1tJ87_dcJx#V=M1RtU7=Rl*`PNWvh6&}M}u8gqEW>><9 z%)f}k3l@=KmfMv&9r_YTGtW%b)#85twOy#A%l*Z~D`cDU>w!CgG&@P1UZ;3GBeMU} z@ymq8Oz5>$nG)tsAdQgQ0Z+RD|AUS>Ez?MqwfRco{XPWJY%Cdj&d!(-YyQ$v>sAIC zmAOe7X3>X0nt2r`RxMih)J9(X7lHe)Bxky9P;&aa5lFN7WBZSyTZA}O4U;8$?p#`c2;ciLTN`N&|U>sT|B zOj(mjYF6K-fdrJqh+%@AGb1YIC`iEbR!AchbZWW%1JGC z*}YWDH(ASxjafdxyad03H1qg1SYQV2P!jVnb;`5#_#X(kPk=P@y5Bd?*6lw6j?R#Py4gPJ zc)t3@aT_^+?O5PfaK{4oJ_s!jJ*aMD>nYz4i=c2tf37rIdA4(ya`9yMzdTIG)a#-) z9ME50dNhLiho&pF7p5sDqn!vOpd=PyqiU(Nbhm+gP7}ouaDN7AgodBUcI;_{wJ?=u9S*m|tv;N9@ZSG&GVq?4V zdp3YH+Z)6M7QOf%2sURZzh@kdM%`}i?V(|*s%Y&nRcu~Te$Q%0n#j07mMw(7_ov8@GD5ORfh6=jl%jzsZhL}5f!Zj{sIZOPef?gvb(yW$JMer zzH$foXN3g!XZ&iZVo(gzGn1`1@;gXK@T(>N`5X4$l(lqvY7Z%TWdvD#OGTb^E+pmF zeuO@7B?~hzX>KfwAg}Jb5~uu%yW#cD&X-CBEe$={T7nT_AOR(@Q_evK>N}dt>XJuV zx;r<4&94a;UHq{xg)6z$Qe&o~CKIBrH zc#eRQSWKr=G4yk@JL(TZBppn)q^-s@%ZE-j518mHU;+))~Brd8^k zbSKp=pWp~6iA8jl*Hx_cBAUk z@&>}1v-PE*ISC}y>SFooVUxJiTK<&NleMYI`ToM(216vn#&gJ>2F;aeb+ZVhdBUo% zmevxw3g08cq_E3#Nc-J!@dQQ6qxF>@I zl*I17TJI8XHJ?W3Oi2~so(vLD61$xl@yM>6l`IHQWJ68wtrXy%3=&Wh^HuBbglpQG(P#AvIRe%Vq}loItA1FeHl`i) zG6lG!g9Oygym#h$<8e0c<#p0z0q)5l0VN@{dHpoJ%yFlD@O(e6wL=0*Lg?A#a4dY8 zAb+Z2%C#%F+k$P3t(IQI;GXe`Qc$~C0rnzDaQe{K^MN#U-ZA-*P9$gS*A zZs#+ULZjTu9tAxkm~DdfCasT(HWf`cB6!SF#j)AO${q!2c0Y>sCjDnl^n1W#_0Fq{ zm7|TeRQ6{`K;3Ld|7n+OVn2=kCz2%je_h zhiJMhd$RO60!m{3S2^L-TlSF67VWI4!9VLyNI*#lC2yQYM~vJd&re%Y z(MtZTKOq4nv9s`GZ+cXICx@Mlt!RmV)}N4ol2|-|8~tcpt}*?f#84#G>`Mddodi!)aA>Gp;4VxevBVw%(nXD&?_z!n>Bnb2=aa z^)X-P5eB&7_ABzGHZ~Z}MbN(ldiAi~UH22B!{cK4anNv%fU^vw*$C&W#>IY%}=kjpFeC22G0m7H=+4_2AmdB3ESFZ$eUPzGOb;NvKtvb=snQo3Ut@P#m zEf?zJiMF>7muZda)0~wADWcCj2Nw_~LX ze}M!)UJYmUozeY-{DRdv9Q&X?D2ds;jwi{D(j4hh=8X&U0SPFHwOv+wx1!g{)mRNe z?J!l)%Zf!?uz#gCiO}Mj2mPgzBh?khLvs~-cHbG=>x^H!C|9p0D39;uur=Jp35y(r zLs~rOWoN1Fc309eB~iIJQo|8Y63fSh3wyurjlrLWo{%KzjpiarP-X_~An>cw9k0tH zoD-CosXLgCw7l?=;PM!}@YhkvE%l8iqHL}*WPUD3KuIhb$@t8YKKrz|-R|xj0aFO| zv31D?&$7olEe^WaQ;L_J$%;It<75tj1k}yEMZQMk7Wj=oBfCj3b{C8f$9wi~P10if z)eWU&(_T{6fnf^SHJ`+(3kbBU@D`J{_hay0gXY3)*X^7RNb|A6JknW&4g5~EdcL0o zwL=1?mU*jh(&Db-owBu-gE#`F3es%7`$>zF(va@io3T2pG+*WIc25$zJzg`{+gG_? zFNiGNkf4G0DVVRpK`qV@(^c6@XHEzF3hHD2yDTz{X5mbA;kNn`)By>YKNcU3?Q46T zvrr|}a+Tn26zxFV%6OoqkxdWjI;-P#?y0`S)+~rLdKs^Q1eAnO zjUp}WJFttY`8{Xu3;=$`>)6R6(BX=G+9jc=TgAKm3AZx%v*(D=^pC ztzS!e+@bV&&1<;R(jCOI`g^+2u$?A$PDP{;@imKb>)ME(J8dIJ*9@a`?PRQ0{vv=R zc5?TRB#+chq3d7tQbD4b?;_#k^#%Cax&lrg8-0d5$iwG{(;Ka8%IY&q#QiZjSf^V{ zAOR(@vRbfEym&g4Ubv7fK_Vk?3;wvP7#DdLax|;+u|LF&C&Osaz@hT_ECqi&_6;|w zvW`FkN@Afbyoces$7<7E;zkJ)k$TlMXj@y&!V85QjZo_jLAcJDU2+F2Q@K~lLmaf* zN^^V3S^^0u38B@=TH0-LnC|{O94x#e&eRWCO)`hG-_DeA| z=>2`2!Pb3T4Z>Q3l9*Squ1pO0F$D`R45a3JzDk3)nG%x&1tel@8tzi7IXT!ro77jL zgeukAk)wgxOvi?sqPX;C7+#&#Nd9;w1rM6omQ2eixSN3DaMQyLERtBIn|5oH?!z@psGS^dZ!S#R;orX{lak8X(=C zdLM@be$>o;wwgc!>SmrC+tqlY)R<&EHi44Z zY1GSw;+^`lXib-aLcP^Tw7!|KaqfcMZn0aM$ z`VjiL%amT4*o)3j2$3LhnniBeTZKL}=|h9B6w52`ou(Vgo@hS1 z<`PIiNeIo@=tWbv{FECvjFn+)fCQ9;km~Ye+G47SeEaYoj?gQ;PfNY@2pN^l=|iZ> z<`|l;i#-f7T+bZb*cAOR&IR6A=VHU5ck zM3)*eB=T#npuxt?NQ{up(JZE{u})6-6@(4n?dDoLB%ma=F8Ns^r;K9#BdClcV4r|A zTbHbf#y1mo=*|S!ky}{3#9n(#G#;6JKZpIF*@R=YctZnO-PwJL1PRz);V6O7i^0rC z*zBR&>|`R>+93fYAvCdIjnr(wJ+*IZErnzI;AXpJQ_DcjU9^^5dDxY%>2Fo}Z+5m+ z-(J}4kEGr#n(mC}E%4kS?8OuI6>Q;rK3oSIs=IYQq-*~VPr%j(X_l%pME9`Tcb)GI zErlt81k{aC(`$NCJ-eSerIgPHB%maA*4I8)_j!|nwC4zm3Jm%OBw&fKbDyL|l8yc> zss5W73d;n}GCXnJCQO&`ewk$7U&|42&VV$#J@9j|WMAW;bZj<@+-v_ys1e<`vUDK< zb+ec?>r#bQ^H)jj(|7{T8IWeDI4OC;a{qRc&*vDf1|b1;BeZPqPW7t>>!iMI_!@-u z3~A=!Gj?wq_5(P7z%iKJ46D;h6@8z=kQG%Tz@9Ll~J5Ecr-+2O#oRCJSM6<0lsCj(ZZsIvxmW zTz2v=0_GOd>?V?DTgg7u#<4xSg$+;WVf5!&<_W~~;=%F{=PTmF&A44nZjjYPu-X|- zdzyz5;liA9=N{Qy%wc{ZVOgNXgH)#Xn{WD_Wf-Cph+rJ6f; zNLgoAR*-;S70ww&8mur;s=HLIGHh}arFRdF^lI3D2aLLCA5&vbgQPWb4p9U>deWNcm7JnYyhK{Y5jYXF&Xj72a?Nl zj5yp>IzDNY>O6}Y;nTu`m>eAX{}50TTgkk?q+45bLAXCJh9kV1V^Z|dMRCvI^|AYE zXKI#Z3|TJd2J|Wj28Mk%SE^8?)D@gPC(5`AH6jXd2L zr?}^@C6GoaC0|Qj>gx$>YPFQ04j6Y1+PM*`%WRMl&;C(MTTCTbYmk7F5OQZR4L$A}dfrlb9lwLXcFgu_altgiHdV~cGL&J@**|R%@lo3=-Ky>&kgmA5 zO=uULIVL?Pa*^>M0VT0{FyO2F;o?Y~ytumzBjA1gsU{D!PRi)Ug#^+FeI9;AJ~qe~ zCu{1< zecdKS9J2F;1j_^xP&Y!EHzaw%$v`~Ap^5zcfdT1tp`VibbuAg0ZA2O-xhfrMu$+S) z%u08AZ@T(JYy9$1T?rC!RD(1^4#5-X+V=NEy@e(+9LXR7B{7eb&6!enk7#^0;fDmZ z!&U-Y4~tqi^MN!oI2teLzC(iAAps?^+fF;;#O$ZD@T<&)(teXrC3)AJ@{S|3$@sh= zWn4*Yxy#W4;#X~~a_viU`J9dw^I%xBP(fw4M;Hjqfi&~w%&aYDHrS~y+7L})RA5NJGDIj~QG2nWxos)k9YtZhU`Rko z%!=GT8vFDeBaG^;FQ*38RT}wuE1#vc#L~H@^5}V_;w=}D3w87qF*riGlC_q#l5^{$ zas9~`g@Du@(vMntN_zDn%DOd$904V%;t@j!dQ%ufRYeu_ag3r~&s!7tYg#^@%&@NLg z?l66UaKa2r(E0)+Yr=YB9`Uoa*gdngdg-D#3Et;|1eC;1=%-A^OT0_PTGfqY*w3x2 z^jB&)WR_QJyOSK->Z)8{y0_f#am5K_{dcqQwE9{x*nfuv`!ghW7zC z=^#U*ZIjVTHT$^o;T?ApNVCXBtJ~wNhEBLoM0FYVPDnsW?8aeozHnC;g-fpMaReN9 zAduEjO4~*DNnB_0gH!*Pl@Ch;przwL5m*gHkA=2Kdu!?aoE8TP!c<#*Pn^= zf-=NsI7}i=eHC4N$4VWL=80+D-LU=q)_7Y37ae>>2@+5e+oiU?A?EJ&qx;H6^Q8;Y z4od-{<^@N^WHgyJcq&NnRWz7ezI2W5Ou{*@uE^aU*3!XO(I5dOv4{@EWAXPI-{nhF z-*5!XEu@*RL1840nQSZiN4QAv9W_Wm-E6PsHxmzAmmzMmc&&p3EFVZSpQ1wq`)xDF z?FaZu@QpJ_KuIhPea%jIiwVY`@0f9KR6zntV!k*L9dOSfguUQmEq{ib9ok!YJEa*%KuIjB{PDrq(m)@t-(ScP zw(Dn;@6|*lK;a`uAms5+5PmzRK)!OpOpfR|i@fypR`!iFAy6)#s+`-txF)_UFB#KM z2VaVW1eC;Lu?~;HXO0uPTfu*%l9wT_@AE5(f{V7 zsqsL&(eL#om_tZFN&o6-YF^WB7%MASK9GQt{?&n+58Ca@bf`%JvA8o;6OorpAOR)) zON8}mx7*+^0+t)3*=ru`Jap3917(w9T5>vI=|Y-$4kX6V=$x%;@1#o{0pDPOH1o39 z5JLw{XsWxVIm;2tPQF$I6U;L?dj2ytsXS&CeBnIqt>vEySK%gmQD^kyvT*yV66|Y`;9E)1 zWp>MAcz)T^M12X$f&`SrUMr}5PEOPxN^1p_%J3KX?#?C_6@k=hOy16{c=LX=VP|S> zXF(kgZI@w-g9MbsPNTA#(A&=qsLjk3F#($5OVQYXdp2Bv_?nl{b z>1o|cp&+eD2W7#uL)|O}1bQ#)%EscUMpb0^$|-Cy{5zv#Tt?8_GoFe8-4@93D@eoC zvQuaFnrDONUv-yG8FF%ec@T=u%IA@MgCSq;`I7)|r?5Z!1uUxK9wzk)P-*&(9^ zef*&hO?fw+BVc_%nt5`h2GjO=x#F$DeiF+`ZUTRrSOV9gKMezk)RL<&4wP#}lKaQU~WU zm@23b((KMpuSmN4yGC|eTt|X7Q%FEb>~5cr7SFd05hP=M?gcRTwimoJ%wD^xqs4a% z6C}^#$rAhpzA^^i_F^M7JH>h67A&=UeU5vV41NV^7Efls7S9p)>KJ@RB={4^ zn(eiC^p^L!M{(H_d}ABF+s4ORvVRnbm$$c(e}>oN-pGYt@zF277DVBH8tBW-w&ig; zAOT<7W%nRIRnwJ(u-6@*BuMbxKKPm>d@Ya7&R2@0uvLL{!>PkEd^HjhP!d9RWxJ&& zZlTz`|00fnZ&^Z``5Khemq%?0rp<4r%J8K~NI=~z_U8+oZ1zlpyE@mUkbrM;LK>kx z?DkGhT`yY4;<)@Byi5rRD2YWQsnMKvrDoXdpXVF_UkZgZ^M{jq(WyHcQu}4AW%x=V zB%p3~?lbk9+-2}sY&&F;1PS=!DWqA{x_2&g;*>V@O5a1=OPG*=k`O9RsY12I9{7CI zB_e$B629mPX=WenScP7gYftZw{wTuNFChUXu}hHEmdRZLn&J0a5kmsLKnrPvdd&Mr z8d8kt>|vuYd;<~^P!dy7Cqex6z7Fouw?9X~mvkY`EDdMxh(QY;$T3;HF?>}M5>PkO zak3e9-Pi@+*mY5a1bjmn(ria>ZGf*N^r1#WeMI|&&H?X%un9*^cQ^@zT623D2ctI^rZo|yMb}D>pOV@ya)|x=H-p|iJ`lOQHwqM z<*neQQ23@Z)Xh9)TRRCi#TcA%{F(#_IDSBy#kq?Q!gJEo9lJl@FGH&Zyp_P;9vGI? z85?-_#NsV`j(~O?NVC)PZXMoDf+Rr;1|*Abb7*VdEF&n+`U&QcQfqQiazA&p1I1HHtb9T#As#yS8JNk zv3>(_L8b$D2MZGLJ{F6ozs`&5udR<;=+EJNqaXoqb0M^%+7y~Ui;7=vj^hZJ5=b*Y z%QL39FrXJM`7wgCrLwP--}!*|Brf(vlrBxELw$D_+NwE6wE|2coFUcv}hTfCL{SW$!92HuxwA-v+$Yq0Bjo z&zGy3@VvfsRUwk)UM^5>OJ0*z3Gn>}nE1kDd0Cpe#u9#J3yO zXwu1%w25ni1n+afn_&Z*`I4j_y~%}}6?e(*cYoBO5%cH8i_$IS$gV&(o&A{4W6P4i~dy{pyU$N4iv{{R{ ziX8=S^g{=Kfdqel@KTXEet3k?q>p2`JBm;rl*C@!Op3&+MJoB%ldn3s4~M&Z_!c^Q z#r5J?$A-hTG%GHZizfs73XCm-P*0Of;)j|3v|iogy25u+WN@Mz85+7>17qPpn%#-J z*hXwLIEvmq9?j{1G*8rXu8-T)8$vy*jFF&RNbr$#KK5EByT6`^Bg?HM7+nZP9pWQ- z#*u)g4|BVuv9YEIH!ZO)?g^xh;v0=|6(X=Znh z_Minr4e2~1#Kl5_G_T|5%_`Ka#%LNHFo%0<3le-Rr1PfrXjIA&dVA}9j)19xG_&xY zh!m`Ez7ovKqbYnF47R@Lq+n&JAZ1gc&v0P{ngWublx{t z**iX$z+2J$Ix(}u58a2KtHtW}A)I$Kq*GG69$W2*ID6e>3U`W- z;63O@HVD9Lx9%2hT|Xs5S>LkUl-+LwDr=J`&g{M;zFFT$t*hpTq3;kRpd=RMW_eRQ zzlD{6fcS8cMZZ$>PFg?h-u5hp|}sGyVM|*NCUP5#0Hf0fiO}Xk+268Ml4x z@m>F$a<$I&D6}X+dkeJbAk@DLp)JNVrH7BDu}Fe(Bu;(C+eZ^wQaOI?Z z#NShuU6q1ZU;hr&Eke~tdP?*`k>0fJYgzwXzW|N-uO&S&xP|^C(Q#wCyf;`4F58sk ziq6I`B}ibNc+B0ktlG)lEtK_j0~zim!JRhZ?tucKg|tl%W0fO34jFeM;JzZ?^BW}J=hNo=l~1{uAyC-6gt{V8yhw`uL^Y;_ut4Faaf~Ap zqmW9j-ksc6Sz%}8`5GSF505voqeta)WcR)(i8W?fOMMAsILnDq*J7MyuZ1_1%s1kc zARcv(@svnl{3Tx>^!%vY=(JqP+d7b;780n$*9Wf`Bwbfj(5lpIAnZ55JqBX@Zh<$s zN%bkSv_bXjG3*Dp>l1f_^0?RI14z?$r?p{os@w52|9cNR>-AIS(jJSu(6Vs)zr}Ji zS2a?oNOktwGFu>W_id^7coIQ71$CiF^W9F}FQ^e;`m+-wD%-u9H`l8LN6{zGcG!?W z+x)&Fzqabdf)iOtgJ$+MRqN{G4~J8aXWJ2^>ZcWDqCvu~<>Xt@^p>gn_7 zgHa-ZN<8+QR3p13x$2k3N4Uow&j1k59JrEgo0h(IWfF0!6vLb9@x%+H`Iw>B zF!fSo6Vk*thd|(o7fACPZ!>*VcU5dpn&k{q@s4FA&^ABUp#F2sFSm`<9q?4cJK^yu zF;!W2=Oy*~d{W(-mS%Wr0NOy?{66}xEm+Nh>wU|qd4>Dvv3&5H5k6=6W+c0wWKrhK ziq-IjcT6GD{Qi$a-AIW0X5V1#n}#PIU`oW>vx~&_Ao*_1_4TRxK_DDh%F?C}H|@uu;tqZVtYdKP5w)+r26&p|@G z`MuJjYV6#}>@3}qyFytZEfPKhDzj$e{?(#-i}tf^Sej18WvK4eW_ScxQ`xF zg{8pf`gXlnKX0=!ZPslK%N=to5?+nhX!|N{SMS{E$%gRz1+$D9oRZOHC&e4nK4!9s+@@OGxwCsMg-BZDa=W(EGKPyHzl?SE!w`vF3J) z_Cy;MUsq?=E%}LCCr!hrkQP_4f)2FND%9yu%63Z;2z&}t$WNx|Tw>E*yvVANr!>qV>WakWHn~~TJ&noQs;yLf3hjx+%)tw^TJ1mjJ{&nl zxWOLF2W|6{RhRNJAeUP9J+4rQVT>RW;_dSK!!9hbvd!wAF0DXd=_1V|QtAz7P1jDg zww&9j=euU;*-VNpx^;800@VA@?2OBeUu@_8SnEgSf%ymon3 zCV{~6L7Jb>(4!H1H+G5|{V@l_9mHr)v~f5r3)@DM)W#R7K%7jjL-)3+slQ&GK#}J2 zK7ZZRYPR(vS@Pv#xK9)bw9O;1%lR|!L9LSGS6$F*H3^{GR`}5W8t$Yx$1awYvMCLF zTQPm|)ID7p5?E{R?~bOAK2@c)#_!^NaC^EYEVMy8-xn|Vx-Hm1U8MQSXr=(xCtvgA z(Rr(B7%7JYrjYMX_mJi z)msZ?As@K27*or~SA3sZM77dvSF(+bxZR3A`ykWelXg+GhpFYKs;Hs%W#hWB(`&wJ zIOmN7DsfL;xg(2yuvM{j$;h&lbD^J?PtaR(|B9&+S9rI0uGEfO?$|r78Nu?meP%0F zZ<2m%-!6ebC4OVbh%U^(_*(Vbyi*zym|L{Rqd&iuV2vlalRVj;YN&-N!4+k`n&Do8 zl?-$zBZfWKT$7&K2DF=^cYC>$A}tbUhws+NnXPKzAKitVBaNx$F-7;>twm?muO;)#}7hO zdYrSIRdeY~YM0BvuBUsJaX^=bL8q(kot>Ye#D^-^7`>pN!cqDY_;pMlJkfqebDWb&b$omoxS z=6e0V6k0IvE{Z7;%WA^V&cv6;r% z`z|ZPU6{DbG*|c5dZs()ZMjQ0`bU95?MUrMZ?&-}W~n<|Yw9`g=BHZ=?xOe<(tO=u zV?7f0AWj`sFgwG2qFYZ(T4BMoW$zyMlOFSAlSfGg7$bMxkoP2xyuuKOsZCJ>q<|EAefSN5O z1L-}lwm{$+0ODT#BEy;5>Ud4^Pu{!&flsBsH(bxtx^~LRL`S>2C*>h+zXYl!|Etf? zI@-Y0@;k%szg8EOvalD4H5rx(#+P6j@_j|a8lnO(Phl7ip0oqYkR)lG0i?^c~Fb6HFm0@d%;) zghZ5Go*XcvtUzEskmm8!H9L}Vmc)IlTr)F#3hiN@_}P z_F+)Uh=1dSQs8F}v_7Jqbm>V7W}BmobLY2cgo~UCQ_Y&#@?ICTkEDerP%k0(FXX*U}0-wU#Mcz7(=OBdIlSzZYxt-=5+@OS z3Ta%y{WeN@GDPNYTR?B-atTwop+FxNu=T$@_cGM~HY{x_=8sqQVP zf0Kdwxg0eqo=4~SUhB>>7D}|w*xyjN(;Ep?;t@g~5n8J=xwW`CBN=KTfpMLD7eujr zwff%3L^ieS$S{(0{GMgn`wuhqtN}#0xxiw%(;}nqh5D7%4K)J@Mq?r&+UU1GC8=(s zf~0(drUaitdm^D6J*pO}QBq5M+Miwi&_)l5_S5s`_ojGSj~FjHcep>h-2bI@K-zy) zj8DQ?tjWCt^&9Uy(*rrC@P59fYnZlZdT;v-x3LUA+45ncUf@eZI{xSs8!GYf)yQS) zslc7qv^z#I)I|c7ES49gJMnW~-zj5V(h=-Y7!_7CZHVq^>q6a29OJ2~88wcbTNP(- z8!}GAPcTXgmH6&~1cPGX&jQ&FcmH6Jin5;^__T>q2QH8s(@lFH0xqz=t>{+N<{YDel z&>42r#is^z>8mFU9zwfE$MLfIH0`nKS-%%)R&|RVKf$!45?_fs*pvi6DL{r_A8bcm zBv6T;oe=-nx-9EZ_Uu@ahHqASDvSSJ<{r1%*sEY(D@$Z9khpOGvy0cQ%D8uzkTn!a( zyxsEfrglDCZl&X#zU**`el+=0XPUz@#fJAn@X~eOcgTIoz)38_!1!cs5fRD?2~^_O z_GWmZt{AvEIqT(K47ISNMMAALh}3L)OnovwvmJHu{)a0eeQD*0A+&9bqm|TsbXT*k z?oE7a?zH14Xb+Y6DQQhwv-huDiQl9Dgc~Z5KqWr2?QvZhJcr-EQ=~Q;nr zjyNFP)i`$Ia60SBFj~u1=c!6qQeBysGmxaL%&p=lm=9Fqu{aAZ*vt0auAM(Ul;Mqz zNT3p5mDyz>FZ;_X?bVT>7LJy_Ng*_$ZWrp+&f$G#KN(93=UJ+B8!%bJPtYDJ@tEyW zq3Xq!>JR&kt& zv`9Rvc2(=K^Q%@T(#HO}L&(wR_4x_o8g;9eX+v1v+P1crblM`rd22|yVaES0mVc{V zRa@-*sy0m}Si@S%|00s-Tk?J<(j-~f{cGRUTeH0x*K&S>HqbU7mn2!p+iTyn3#o+D z`cH(@w#DMotuL$E?Uq_1VXOxmXzt!@`1MJ4uI0=j5~yUcv@0@# zcwE`2^?Xs(kq;+<=fWe+quY`_$h?x2Z2Raglob-F#CHQOc&Tn3+LO%pDrvp&*Ea|Sd`~E_p5(|#jgY^@tC{$GO^#ushyuw zfuVIw6)IUQ`*wSG*}P_h-7R6Q3d5V=}G0?P+!?y0BLOZqEYFgfb6-Hv5~1S(l9 z%WF*4PJSxH1{ScfZF4KqyJ4v|15KEJ!P1nHlv z9;sS4L_q@6jx^t8THuo!o4*I~Z~Mj<%LEBjvcOLW+Zx+Tf6$>IedFDq4t_mEKR7s> z@%Mo9!}NXS3K)MIKu9VU!WL)krMG&K`V%EXpvI)y!9(IPzhZiNm`2w}U|2{Op86|7w1J^&|RqqF{ZX zF4Fu=j;#Dvl@4C^8!k)iSW`%#lEvcwWh}FGp00I1J1-dtEGwjW)MVv2b?DQPtV)(x zfxtRInn$Y#lu=ua3t)R^S`=(?NT6*V2XHii*hd#v3sSkK%VF z@b`HO-Lu9JTB}uW{lBygzyH7ev?R6l**c`*x{U13&S83{*DZb_M5XuSks8jbvvPYg zGEwV?4N+-eg|}+!JbvU!!8Z1gY(tDxIoj3R_rvv$b6RnGgvR*y)yLoKVEk>dyqP$N zxOv*O=h-u=mz($3A0B9+|4g6~pR>%^nbcXCj=8*yQ;_Jlc%XjJwSl3F_W1d5L*Hud zE&bTzO$U>a$da*#e#gI=K_JcVWDV@fXvMFZ$M&mAajTy`D05pQRY;%`pJ8d$nUy%1 zj{LD`tQ`r=Ez}abe9r)~X44V{bBF{g@krU&&Mf`sbfn0ghgPg9)J582>B#Sb zc+vTMa#!EK>{x?Hpc2pd>o?ljn>|>eOfwZEuzZl_F$po!%>)~mTcjb#ThUyd524iE4%fl5Z5tByLCGR<_f5+}jyfSNrD?HP5>9Cbc*!E=R|B6au@ zhYci9$*6PYsPl>mXMK_2wMI?PfV5HPnxoD=f|?07Ftb*?$;+}(Ayf;mJ2ZSxZy zLf&c{{rrsg;E$*JJN%V1RX_gkDBYIfo^1XpLbOT|cC6d~O*q=Z!?m4R+i&UE$%-ef zNMLS}=66o|&e5hPjbfL3W!12iAc0E!w4VNNRlSiP8`b^36$z|0fws&a(}{T1Pe)|s ziX92eEz&$y_I5?=7EDRN~(a@;4y0hb`CAcFV>lmk*^$UsmY* z(=?@ZYlc#vm?)jtYtxl0X3W4!8S?L>d==~e(j88VU4yA|c ztpxtkxiwv)W^37>1)q;{<{SxB z;*puT`m&NQyi~V2=QJetUX7*+|18tab-2!CIVm0k*+AlKR|EEkz_y ziPxG}d$#6KRyOo%Wd#Xup9p&3aD=|L2c<~!QO3JT$psT8GW&|=8rBpNsKmcJhb>S; zq6V9g8yRU$nuW4*6e1m`@}fP$|wJkWPKpB(U6( z5X=+Jk7&ahCr#51dsqZw-rH$<2|Y?L zb)q&!n%}zcuq7+eYo<1`d0N)t#7y1va7dtSz6(1d8@t=J9h1`sDM;+OJx4#ZWPx7e zwT&Xp&xhMOH2IH!A?%-YOv4t31S;`&RM-J^_VmH5^rqcbY;j1SQfdvR{x*88v#ct% zou${Do$BF48$TG4m|Ak zVc6o35L*cn*bk6Er612bQia5{^O5?Kf1-^ZjI>B#e?|h8L;`CH2~=_tA&yibfpvhi zLGY&?KOsWfMFN%h>Qd|=E%Vdytk~p{mc=&bImbV|dZ?B+yni1tp6%uHhpMV+Dl(+{M zAh{>4wj&W`Fm988Y9m}gZ+EwGM=kJ3+6i!<_p1S;`0`W@O(dbUJ&cKT45U?bPXa6QA@bu{6~ zDjO>Cb8F)~Nao>`eJwvT8MU$;h}Oe%#nMIbf7p=bce3uDL=tC|Q~#N@Sg=t)_hNm; z#VES7;Z_^c7EAqy!DQ(3c~K6~gw8d-0*4QY#I zq`wQh^=}t)U`baywoxQdiJ!9_R+#DC8k3l4SAih;rs+ASEvC)>++su8VtINnh`r9Y z&E8~J9f5GXm&RXQMNcKJwIR)8SXu_LjJCTK2)bd+HGZbTgbZNlcLpuq+ z1PN5)_ZzHTuU#24f*o-25?Ug*45S^N?Z+q_ea5mHGxdO}aYm2Ac5D#ee}ypY&q$z> z@o-2gA`*4S%+~)~9c9=+`n!B8$gn>nfwq57G?y3Y6{{^ZY#=QX*q@O=+rKAHomik( zdAHiIfwV|qDI$TkokWP^je`W157PV`$YH8)biDG%9>71#leivzeGbssTS-kElUAtv+ z_;zZ(agabIzIL~{2BWRL$dS^E9lpd#w0=LIwlBQUC|#uaU380G*y0LZ$*5V`lTTin zN6mK>5~##?vJSYe9?Ra3_^+(#=(YdT1}gFO^Y?qy^jn9L(i28G-keUNYMxNKvu*0T z7VYsn)}IHHb+6}HAIQzE^P7cI^NoW9Dsk^KFPP|Cx+#~#nLun^F^$$e8EfPmX?{a@ zzw*SbjLNn?+31Tlu%40TJ1uH{R$s{7*``~Q6s%h$P)U48;U`Z$W>fPWg-YT(3W>M; z<_q&3g*1;N8xcgB`D{`qtckEAQQvJEZ8?82ZFqQ#4QW1DTF1}qKR(aet9fIg29ZD| zzBbX-oy<#WL}pgcCJ;L+%%skD6xb8rQP>)g5NiLY)JF?tFuK}hk^}Q;rvm8_dx=c_#IUhrfP*+jAI$P zW)fN=whW{VpUC~8<0pinm&x;AI9<Dx0#*HCf z&vR&nT_$S$d$@BB+2OAmXIZ8DovSa*q>DMnx#F@n!l{{aah`g2p*Pyvdg(~bL?41r zAuY}bW6qJlId;*;(#6|Uc6JEaJj+$Xv?GB^{9Na2^~kZ9zG~T5`56*e3P|(QTW?n3 zCxz4`%Uo>=dNTAlMd<>X)-8fQh~Hu3b4$e!`TG5NMa|^hpDidJMx#qa&~;Hu=G88eNXo9`tg&^F)68Y|bDXT7Q3x!afF zw}$77XX`ujN6^7rcKq~>wEmNd->~qT zkVx!vTc*Vn_aG}ny%-+}Q^&~rrez*q+nPs%axAAlPa`NFy;8?$IraZ-!^@KvU1q9j zI(xA#ZD-KE8RC8+L?xFU`PlQ`YqbTt6eeo@uo2iIoGKn`zAHV-&_ONOqabUOt{y)* z0zdgdh_+V`*=v7%Ac&-iE2yA#^qiu<3K?-x>vX(7sea1cj;TTdl`NLv3uD;$4|B9) zpL`S~&|e|V&paFC$GWz7t0g?hnvD5C0+smqs>^!SCwT;WdSj>+bBF{gSuBtIg4Nge z$Fc6kjwT_2o)c+4Uvevee5p}XjlZ_L77|!KNLwub@c65bmy*>seH+`cOprh&es4y- zGNeSCvi2+8YO|YtBK41_R++6tH`^62E8X!HO>I|e6E|45vXZR(uDY!H<5~Lb!mEEF zM5SY@%+7RNU=P$QGoBLN@wWWlcAYjvr6P}~E6uvgB$N}P^?6Q0RLb^oG@F(wLLFPM zn}VOK7ic0aX;|Vas%fziPWSTp#lH>hJ$Ay(K&Op!;iYiZx?@ZHh2v<;_-N5~5O*XO*CrZSfHqndhyJB=L1U1qoa$K$^du7Y4K9bqm;c zRyo zdv&hSBW?5#(vtU&pquTJv8Iqf+eRNG6?q>_s9se;g7-UTtO3&8iw5K)*3-?2_tQ#t zEJY+xiQk<$M^5%08B9vAo~9syC5kk^C(eC|z18RtZE&t}Y)-@iTCiRx+d1!fbm;yS zv}~qvw&WUfXr|C_Jh=^`XUu4u-+);Hfl549t>y!J z+x87ul~3-(@rg;Kd$5A8sn*rjI_*q>=JRlO!W6%9MKycl0E3u6zo9Mv?3ok^RO0(_ ztZrJ(<3mVj|81J%6O+JOZpBoEoh`1`T2-BVJXnd~-NZi=rjo_7GD1_vml{T%^xLjs znV>zPM*@{BmWBWAQND#tP>*|$V^}^v6Q&Zc zkMQjFzZ(x_1?%tAl075n);i^Eg)WVtd77-GVdo`V!>bdh-m{>}nAGUParshEJmTdaR`9 zPsG^@^&Uer*I!A;tMhDczD(q8xACRf%Afg%ur`nFMr+T$Cf1hq-3Wnjq{>vXSaK+r z?P~g~nyp!1Bj+a3bj(T`QDClZ@Sx!WZL!?1c+oz7fl4kM^E6Vmvg9n=>eVBKJJv-T zncAjT_rV_97O&L_b7#F3&+CZZ|Oae<6X+HDv zXA3nZY$Br;7v*wb^OS72BWUo8NLuRZN|OCW6y5B#-B$K;nUss^7V+=9yLr2coZ>=i zRp`n}%vzU>pUi94A*JMk1+;Ow?G%;x`ryNIr25OstnkbT4GGUKeNsBVo=feSc2T4) z7M80Lo4LrB)Y^SH`NFY5^kbu$hV}f*yV1bZeFp znv>i;VYMPL;N@T%Gb4;PUEPx+&Ch2b{n@N;SG3`A+XNdGWBbrr%_0o~X+HMZ+>>>% zz0hXHJWn25tvfyVZK07WB+xeBUt2-bg0>D}6`Sw!?J=x1Pe~N5xPF@r2~^_qiBH^F zbP{iKmD(0XYRQtBY|m0d;I*Xd!yLY(M#2S zm5Nz+w(Cc`*{0Cik2M<-sKjT_yR~Jl54n<^w;v}XfhCGG|3-iOVbZD3{8p>NH-ssASIRhZwu) zuuKj$&S;c$M7<&um3~hYUs%Z?^1o3j(iY2~fpPY})q>fK8lk>eQ@JNsHHa~DLMhVx zRL3Gml6PMVW+SewPwu#>nDNvww{U*u;|~Ir_*qC_2PW6&M24)7g?6?6`fZ~W611fh zl`NKsI*WWCv0&Drgtr}g)UXlD4dU$MB@}6%D(@-QG>`fB9KX2aSG1_{)DAg}B7sVL zy;ho+)Qi{0t9Rd$uynCR#rn8@IEOOjLNF^d?7w}eh4qZK`QD6!)2t6)1+%tKJd;ri z2~@IJ^44|VTQnk=?aent$T{X#EM4z850d-d3T7`7iVLNSwTAh#SeoiP)W~Jtq*1vd ztnK|M{YzLF&7w3@vz?pA?>dO2HEN}`74)B@Z#grQu6t62$WpIv&s1%E5mIh$ae-*? zGD_dRVTnN?ElZXKuH?k?JY+V@&)OG`)r)qCG;AP&wq@!1(=ue3_Mh75w8YM+tMuq` zOAYHtppq<|dR&@p?l4U|<5xo<&aa8kr;b`g->LOgwth9(|?}ONofp{a36t zY#@QQWhvzRIdxpu%52&di51O#GDety0$ zFI^;1NtXN*+A`M3E`>0jTQO};!{H z=J{xri)D|xr>0w0nU!ikSwCQz`wM|evb6TxVr^8nQ|Ab}cm61>)^*{+Z_S_v^*32zM)+Le<)+eS+yW=rHPj+(62JQhU_!1|9 zN{07gruX4qhMFD@^MOjdtR9zU;NhGE_iEJiyGR>el$lF3zyhl0v0TO6imWrO-uhw=gMtY2_#9GasPN&V= zraQdooTlM4zUx}u@s7H;cs~8_=yrXr@s2w2$gcLfU7VbdL}FmYP`dt~6$XK{ES1gb zN+yNpCLo!v+#)Tb4#mEKDx6NJEs5g&ZD^x|CR9SVsbtWGVN!N@Vig zyY|RpvctnsuTPP`5U33?%)8Tmj0ZS$7sk%mm3RhVtgR@mX;XwxP$jZ`6lO0rb&>toyK zMRaHAuQpIgmX1HNYdvolXD4}mAQ5#giiRsOMn2GgoT8mpyc{C{>hm(@7Xp=dSsgFVMg%3Qb?#SYgF{!+qxJtV$_j0u zk}Q3=S(F8YC#fGJst5$OC8TAk(7Uwk>6C&*PAtScuP&lZr~le^(Y8?^jMoQo)Caa| zw1G;pbo%;HHN2ZQdDb&O%XKnB?-RP+$j7$4^Yzu`))}Sicz6Bi9}Q;aByTrKgr##6 z2%dJ`>>o&T-(LQhdZc72QZ~ht)reiLFTb_HEGs90wq?nlT#Ss)`$%n1iW?&`vwxrs zRFb9D$BUC;L5bS;`p}1;TJVbvULU%tBunpa7UjJ*N&8+OPJ)+}ZuSqfCrejcs*vl& z58LZmYqEbg#OQvL<`}I630@ygC0SaLI7M}|tv`9Y;`O1M{R0V9lBH6-kLMXzi7n*) zJkN+wz3aPKzt}(}St@s^v$lF+VKzQZ9f5F+MV9tjZ2Bu_o@6Po-c7B?Q&(2f)tiOg znxvck0|~Uvz0WI8X1lah^Sn@*<-R>iKR-L#NEH&OBuk^aRA8mfdnhZuS_Q%}ZfpB6 z&PWx~vXp;oW%fBXMk!WJW~U~M)mOGyW7t3fZOc;l?mR5+!DY31OD|(Yb}@SMF9a&d z(wnd`n!RobQh->E5t-Sl(S~D0W-7^2Ww$tO)*mIwo49HMfh`B67}qR*V<(1re_7;R?Vl_KTixM z_(|*-9>ufoxL$Z!tZmeeI663Si@rL|dQKF7pE;>c&oF!5fqcCPbdf+MS*kMn*xsBe zNwuDS6NxH)SJL1*f9Yrbh_xXtOLu)ee23=BY8~<=m?UNjr;myL-wyai=L?*Z~QGwm(IDe=x-8B>Y9ty zd^?n`f00ZbBLE~&NtV()?#Zs5oMm6p_Nn&d^B`Jy>t*^_$(NRGR1WXZAV zwEN&2MyilNC0RPS`AD*LVhxgeP*sM6$1EPB-|o6$18G?rSh=XW_3K@2Qmvk>_2?M7 z-E)ue6pmQM(#>C{GUCJSwOPC;UH&sZA7&%1M!G}64EkA18Tt2adJIkF2|T5C1^ z5^>D1fdneaQfP~oq}kIjtz~ptfp|V*HT_WKh+zY1BOjWX4{qK0-I?p^W}bLH^dGH+ z=YyH~;99zwb|g^A$cJX;0|}ld-OLlxMn0IC51w}2Ogj>&Buh2tcUR}+ys8Bk^kaQq zj@FAW*=v*)Z?Dd8_hLS74e}(NmhgKT11GCU@b;>kEgxxFnxpuTLM1nAjlLCO1HAn8 z!x@ekHjqFiS=u=esTe#x+b z1S-i={VBWE8t0jst8f>B1aHT>+47N=rD9K9)!jEPson4NVdWZ!>v0ut7&eeVCH{?t z|K#E4i^{KD75-<+kbztEs!9_YFg3I7-Og?Lm#Jy^e5=J~{-4+2nP~|Q;>$}{rA4i> zDK{@~(}PyLu>C|hl{gW4aHoTq`$8mw3sthUxwB38@AE{U`M6}}Mc?4RgIUNIHzgu( zI@{~D+x3gn_S?2hzt7z7Z`bEONVesEAEwQ4-J$Ppzl!@^GatEmKI)GjKz+__(-#H5 zvDGR%lbUJ2&}*dgZ&_+tZ*Owg#$dL5g0Dbi*c?tr6y2fcE0iSAvQ#U7iKOZIgIU%3 zn*{<>h&2CJWNEZ-YnNa)?@73VxkUm~D@*AQ_NvvTM=@xE>SQEcJ!ZQNm1Jpak;omhI1$5%HI4h|6$4l3x98TQ@tLOU0oKKO`FFMHx}DSY zFP2rh+v)mLmU8~v$Y&fUmU1G~=SBMVK7;l06>89Ob0hRIw-)M+iq#fqS?WDBLxSJ; zR86C?)O%r+UMQ)ygP@UQqav(kfjZW z8+f|~?vgGD^zM4rizZj?gJ3EF3uh}FJs3c3nGqY-={zvKZa?_#QoFy$wmoG;r zm^QFJum-VajXL*&I;Uox^LnOcJ#(6x^)E{gKG=2*{$7LYX%wAwf1qCSL3KwxJDX_9C=_N=8deFk2!%h5b(KwRetQ^Y;Iq5B{Bl zrrW-bZvC*z_VWESTCnJL8mBB1`aFN1w_32X!S{S1k+I$qIz-<<(;SNxXjw|=pDp2k z`^6%SW#*dixaP#7f?4(F3gfvXZ`j zzeM+XQHP2|p`075_4#fC2@iE8z1w8D{$H_r6lq!N`e~N8nGYl^8CTGxNqn`bZheZh zEH%7S#OLn)YU-+=QWe;J8ND@bwSK*A1B$dPEt!Gh7@U8 z3SQs5`VF3ssGo?8*A~+*(d+dNWgAhXWvPyQV^{J2QC5@P7tu7UHtI8PH=;;$Vw=mZ z@|Wzd1HINs z@Sduh{Tyk-GwcS>;3T*=&`n>0wBdbrf%kC|+~eq`ze3vZaJ#_6ISKC7bkipyZFtcH z@S;wFdrsZ-yGZky?VKBTnx2|_WZm@VNMOwx<3w*5CpyPF_!N#ZjrYec^ZkJ~kie0X zEN$LU*1Oa9UW+3#968~5PL^CZ$N89VHPk`^m1JqtVmI$b-+c*=?(DUf>;Ft$OFx`m zZ4-&|yLvd^QAotLU!b4;w1u`lxZH-cEX|HN?6l#Sr!>b5NT8A|-RjVBmlsc!<86Rr zXCzRGm(^Vl?;+pI3JJZ}czsjx9W-0$QXA5IT%yK#_xn!ZXd4MslBMaVK08OqNL1a| zMK3;?2ho05U_)A#4z#}OtU)B$wN|>z#cR~}@E9A?ss73wC;miGlR)|x!ubu0KjkD) zNtSE{RweZMo^u?x@u!?L&$;t^n=H-PJ=7RkI^Kgkz0}Mt5~yV4Ji%nUktZD+P-PH_oM)ZNZF<{8w< z^Y{7mZ)x4|F__>}hd#~M@6ElgA3Yjt%T&L8%BqRi^vbGZMp&+qu&2!>7wbblhm1tS zh^2bP!xxSDU8H3x+U-QGBlG(vh4Oi7&mpn8?tVtkl^AP70+nRx#MyXXmxKQHB8$X) zAT1K#Lg(9?wMtAL+*~BKc=5esdE)iwUaM_L%hKFgPkb$tg4xIUla&!gJKDzFyr~~f zDQUxb@%XwuY|S>@&}aIu;5i>y^{HB)^?c5d${E7wXfbB+Ys=JQu^8Iqs(3}&@F<~s7>B(R>5=5YYI zU6L1f4rWh7!j%dadZv61yrFNrw9E*J~MEVk?E}c9z>3XG=^zqhzjM+gXP)U|LjwNK^xKiYWTX_|U$vnb0D(R|W z18G^hU#mD7H>f_Td#9y>bL>cNl>Qd3nEc838y+=gzJf@lMYpKLg1 zZVe(Nar7h-lEhKe#72Q{Tyjons@c1cJ57g zR^(X$1$_n*m|9ty^5~=5K1~lc=e5OQgPQq3U(1!8KA5k#Z+of!*QO_{ob8wu30#9j zT9*1~jY;06g%~L{Sg?UBl}O9d`h(cY2UBXbBC;;2}9g%*kbrB&ZA&fJEOjL{?{{( zR5@D-5~#%2SoquKcIVJq^pvZDqtv*+;`G*KqV=n@&f0JsEKAw11hYkXLTa@cmsub- zKa11D+bq*}e>iAEnval2@hEc3-?hd*3{}caS*5R@u$aCXG1Z0y+U9GiJXN>+pCv@| z_aLSn2~^@EvOB@7x=)UM#oIj}AMZunf1ePK9qRBy|$({FB zm&HbXI9Dop9XQ`nJ>IXP)8bape+s+vJ{Zqe!5i}W`0~#gE55z(DQpY84{i%4Q+=u@ zmr1NAv=WRKz!t+-k&g$H+)o$m>+`&{g1ShEHePNHBK_*r^9_4@TEUq+Bv6T`>S!>j zF{p{J+h-3u67kRC=(x80d*|DOKgBb2zY|R2Z$UsVNX8j(OH9=SZ$o3tP1 z!{*<=W5t<9Bv46~q$MxP~&6+Zo!IKr7 zn?wSYc>Go4H)^!v$9li%ui)Gy5~#!@gu>(1O1p=#FLg^OI5&v|D)E_#Z|TVNN1d4T z_hNy-)lj5m=|-8(YQV4wEM3Ee3eH&~fwpBB_5O7q!Pu?3=@vIOpDwKqdY@4;#%A3NO~ed#)DPVl@PxGG;A#MrdJ;ZT)>eHzN zf65uL^%LQYnM(b}l1hcJ!KEYhMTce@?s0#mk#nR)8@S^LW0l0% zD71mPNT8Bv0|^{|A}tb_4qrG8%C;5 zHy*8D-M!MVfdnc!2_scV1T?CVk}~JuFZAz;g~4yxq{KZ2fwV}V2S5UCiv)TABv9%1 z1kae|gK8$nr|GWMH`qZBv45tFuh2i z((eh}qlWbF3EXjpv`FBNBP1}jzbCj~q2{;*X_3IaR!E?2k-*-G1S*LHdH^I)NhBPV zZ+bN(P)Q{CQ_fKs5~%chV!m}0{cB7Nb+mk>orKX6`BS?2)K7%dwv*t7LmayYoH5k6 zV_A%+cBC=&M^}%jD5clsKmb&4Qa}zu1iA>M66Shz;OcFlcjovz1ay{ zw0gf*Y1a6rHKpd3V}=bRP>DwscdWuPo=B^GimgtNz_Aa~d>^E@Kil&zPP=NIr`3I& zE2UN9MZ*RXs3c2T{0*)AA}jOXla?TXqXDG(`e6NY+JI%{SbXE6}D8@R%U^zUup?jxi{8^sPq>P>SoBOgehZIQsbMFN$?R3U+F z32D&=w(5S%7pnUMHQm_T2R>i4fdpy2pOk2H(jbr)3GAInppr|5@`GPM4@9E3txN0uz|El;JXM3wCyB9 z9Ic&~yYuag1S&ZRqkkZQ^^CNWFl?X~MFN$agkb~sZSrGuTpYSi?PNdKO|dPZ6# zu>Bx`sg)(a5y7nK()^0ov>w9xIj&BNYunu~`LnniCsjS_u7Yu)NQmp_*#p|L%0pbq zjT6NL;%4Adef^>hG;(-p8`82={kR)jn5z}3v-OdJQJzSkZCQG9@uSvzMR$_*%0z)E z;2N&?aNSPbNC_L#LRpcxFH!o~M<>mgD`#X)1=sod)ts8~e6!YIOXm$OB+#}j4VzcNo~_VC``{+Q0)a71Nb{(d;oa?xPCQADn;J|ad_wd# zX$~7UkU-nAw8yWAy;M+T`@sm2Shu08-YxS1+K1)0A>tkFwqBgT?^%NgDr|j7%TkX60nAb-;OmgtN;}tz1a~wH$MlK_PN<0?luRdf|!`o{25|bS{ zcM=y~_o7)w?KNr;Y3_X*rz36u=s;HVFRUREA3KrymyM^{!b;na=JC5nGLgRL+L8Q4 z(`y(vjRY$3=$)Pw)Fq_@NO){9~s1`apl3cvuqT=2+ zq(wqH{Yb5A>rFbA^-z%5U4J?Kwro9(^eARSn)j&7-N>VkpVYzm6NTsp1|F^0+McR6JET_?f)pfggIdpK%(S0};k zhy>~)%}-Ss^;D@(A6c7v1UWo|GlB}!E=D1ZRio^$NHgWi=OD7?+5)=UvV$&n&1XXb zmH1B94I7dd6x?g|xoP-BXKs-eiMLlnt@}oPv@YYZMfen!75Adl^!ePzgIM1Q`!`wt z1JDA8!GWrhfdZ|{>>)YD};*#_B*7FH;!Vyaj@KxK-ol!Q$+4_V%6>&4x_4Dijf*#IAk8DJzWG%)iQp1S;{93a9(2qdtsd1*<$$@F}F9 zOh{r2rlgjFEG^HOh78!!m2G>_PQg!*5O;G#*Q-owRH?}#_dFE{d@6c)nUubsno$a} z)VIwja_;UDZTX})1wX;v6TldFP}2LCXdjB(&f2z z+T%9VQcwRjY;%8=liJIUXsmqb7fetW2~?7$?mb@A+I_62ZwTL4gyoL9V(IQLS;9WC zeTtH@F-S-i5~#%EUZ-ZaJKmf(_}k!EC1P7zVVhoM!vhAZjsdUS@?`_Os(Xz zWxA0Sk1PaFHbDZF_?-T+EW~!e!p^p;KrYnFZHq3jO+OL&(T220Ojurqd_I$fT+5V6 zAn?o@r1^QTMO;Yx(p}l7a;oE89OwBrNT6*Vc{U=*I9>cjz)j(F7~JD5=6w9w)z#9! z4Q4+4{$>0Gb;W%Ax#^KXEchT2m_npeM;Q(qU*FHOVoH!e+p<*6w~Up4YKi6FiufnG zSyOl-jN|W^5~=6@IEfIGFwc`g0+o!iA`TmlvcjFvj;BmoB$lR6E#0yOzY3?^a1rND zZ!uNt{5=vJIe)`*j-UK&&&=PCvP$8li@Hdl5^sqw>!{Yv6G`WxVRoDa#Qq`9K(6zA zr21y*LjJ5WUPA)=9n!KCb!H49Z^G1%`=={7r-uY8$CPPqYmG$Jbd$N0EGQ z9|?=Y;0zulxD#NKnXV}$z_PHoEFRolLlR_=#ogU4Xpkf`T{OP9ExIi3x(mzVyp_3` z{GQtM_dSO{a+vX+9oLq9JJd$U6)4n(sAT0#{h6~(cLP}R zS%2Aar4b3VWV2q(*ooC$@J3HoWvvGZRFg1UE7LE#D4FXoJFXidftKVh>}PNE zmu)(Ufm4Qikie%Gq;1w;yQUM{TelQ_*A}(onjR8p$z~nDu#@Of@-NM`qKF3x>>s3M z&E+rsg>}m$Ev`~`9ajO7KufYZOy8gN+Pg-v>7x$>VJ{+qmb`@KBjhiM?{OrNxGp9w zCBD=1wu<>$CA}xU!9oHpdC#1|nI{tRY!lbykoNX2^YyOm#l$yp3IV>oS5Sj1m`KR0 zmbfN|w0D#U-zbrzA@Lm~66o7I5(SLJL_&_f#5Fmjy`x?DMmzQ*66o7|N9k}!CH6b^ z57JWU_nhbHAv2`&b-9KGHU8>lKy6!Fj3KmXGh_GiVa?VUR#eas{$Q6>&CSCRTdDJ%xy` zrs}I=kF?D?In7@EbnX5u&9mGN^buce*jL{kX}RAyS4TGHw;OuP+1)k#R)WmZO3YkV zvx>h~9nOl}Ev$3aM31lj>#LNG1X_|i*4vC=pIJq1;=g4zBrqcyX}OE;hcTXlKeuFa zW~61Sw@8>ZhuJXRGZ(%y$DA~*@~&pk$uk$eGe;u6Cbh2@koKOr z@SQnku_1w$yl3w4ojK-N#n@wfjMFLxhw0G<>-`JHSF7X=*NT4O}*wuVv7ta<6v?SMFm-i5V?402! zfBItlxJs-rj@f@|W@gjJeMP44d-OcZAA7L&I1*^dW-S*|T)h4>w-_DxN<#v(|B#kz z$bY2>_Iw^Jnx$H*#h-_-J~d|lAuadg7=dg|naZN?xRs9hKJnF|$Lv3(mBh|%2$CK3A7~lK_2PF#!o%z_CI_|LjrSlk(M*{s}ynW&x2W8c^+8z9J6E9 zY}{E#_UQjC?#o{O_|+3X%lIljW4?L(-R!FnZL_}ew~Nig(=qGt3mR6HMglF#b%w>k z;$2J{*89d)g~04_q~-P48zz!XyX9ElFsF{Su8~0BHtV+C-n@td>w+VJmTcDg8Pe$&CN>tiKWAcC ztsDuoBzNAo9-#ML*-PZOwLbm|CRR$v{V_<}tOa`S(lSN%5^s|ItYZyzB+!yvN%dr8 zbAoG#0{eGp3ITg_ke2Ta#*AQT*7ehW&)?r8@3qA0@wn$h-Cg6KuMZ1fd{`@xS0!-I z2huicM48O`z>q<#taX0;J(xJ+aAc@EEeg*%rKjAQL2N8wXSly^rDcWTX}Q%HAGFhn z6!mu==WDnFOxd*s{^X9s%b7&qQ-wu^ zv1u3*=mUMrdDp+F-hPQ&XZ?FA6=;yank;g~E2xGzdC$-zcBN+665NH2w9UG+{y0bV zUl)38$0Hc7g*N$Az{uhsZM@a@{HMxI`H6)bV&ib}NA58iR)s|ZEy-2q3Ri+>9V#ih zEMv;`Kmsk8Uqn zq-eLRwTA1_SPxfSb)K7co}TZ5zZjRkIzs|i%&(?@%(4gXHU{6=Dc5%+hPBqSGzk@j zO8&0+z@yNgyyl)7nrB!K(as_?>_u#e+E-zIb?l#OR2CQGsxoRA;$e|vx^_frjpz>4@t%WBiJ{*CRvzq1H`^;AP2c;-mktRu^W zy0xFui2>bODn9Vck+xa;w0z=zT)DCsvW?Ee*Ip#hx2(mw>sefe{R70bpzYpP`3US$ zq~%@Pt**UqnP~m~lKu?O5DB%f$~W|A3)|HatAc(|h?MT-d{xgVGKlP*JAlGhGau6;C9-H^5p^_zpp4l8?QtjU3qyI%JA4H?-QFnoW81p1ciqP2?ZxpP`Yi!}8ZzVku?Ey-#NZ3DFExN5@Q zsw~5IUibzBEy>l7QfIV%r!VR`CpT6I>`|oUD(9^u+M>H>^&X>|F?@rC1p1bl1928M zYHA~W{>q;e0yjqO3;pE{jMxc#-GU;Ks_k3A}3R z^?23kg0}Nz0L#+92*Wqv_^w&aHE5pacSqMX8tZth1jDQcJPK*KkKTUGad7_(?Ood; z3V}zdZ>rm#PsQ5Qn5Yfh{4>Lh4LnNC{m4^qvZj^3tQ9!jUh#nhT9VZ-GHlbDbZzXg z9vZ;#eKy)fT0V1*%qI@#U+oyPv>wC!8%ZZtmr}E-76%U0(_|Z~x$6vIn5Bb9sku?@ zyG+-op1Q2XTyMuP7YAvT=(whaxIAf_mbr8$rB&zyX`8jkmaU$#m;6|_ldT!%_@F=4 z$LNdc^cufa)1n8A6qs{`nR#eQX79AAtl!ESqn{kmi=kFS6^80eR`IwP#x54m?76bP ziiW>nRUNcsvtAidTW{h#>)w$sO!0w)T4}2OjGM8M6_z>D^GMb>S1zOQtUJcU>8q9M zgjhpLuB3L$VSkqML>*4G_I#~Hafl91gRYb&{t|gXVjC)T zC7PTbCf28!tUvl(Aa3`Eg*+-v5B^KKB?^I-9=)iaJ=)S(FV+|Xuas8^v_vDZ zSL1%H*5}yxRwdS%M&Kzl#Xf-C7)o7iISs!du2(0mo zw5;#br7qhvH!tgyIjvq{_&lR`hM`7=b^aU)^lh`IxjK-|e!fx57VQZNc(>5l`lh3i zb8G>HKufZo%wJ(Fres#np+73>NMLZQd(yAr_k}uN zp(UHOU;fFO|H47yQ@ub9XEJORTC!OKrvj{l+Shr+t`_{Zxbz?JUBPwpq_c z8!_v;6w}779nI$a6>cm_KHsR_v=m1IearW1b^E(L)dRHHxhgR?q=}Js)m)>eOQq#A zPEfdeV5utF-j87n?=>XQx2(!lx>E4>#}%|+0!Ayu%iWFm!B_K);}=SDq;1w!k3R&} z{@I~bSrEpm|I(RnTeQ+xbE+&y0)5MsqAm^fRwvH}Z}W^&h*$HQ^Bkk*82L_>QfOKE zt3f-**xxPM@C@M!QN7h9e)f+pM!PWuIMQ;r?3^|3{GF<3dH)V$IKLu+t(EJd#V^GD zVc4~$OT!q>;Ygq*nbA1$LfqT+)wOn~RpQ2?$vktviwB2YPtB2*RW=J;a39ZJMSJ}s zjA8Ub0)5N1zH2XIH`b`61?3v85UHk3;^nXR;wgqMbRuoD=9qaVZt<}iTD2ysk97sY zc!xt{_@Q1ioJh+j`o}5U7t%Piu?xf4x_gs(`)*Ts^pr_XB(Syeo5ey84tY~XitDPrM+w?w6A;wti8$&zI<>lXwaUw0hoV8?mOsBNjwW&XcGn|)@K;QD)yo?Ug5TJ+or`50foOmP`j+>( zLyvvO!Q{r?Z_3ftF;Iw7P{^~|#4lFfQ(bzkP+RoA)&r?KNNXt#=M60bI@7atjNO!}A=5XnAI&2RUcxZZ*H8Xk3{ z`$TT@3pKJg36x*8IwtcHn|=E|=F^N)!RP}Cv?OaAmafl+uE-_syE19`3)*$WjN#XJ z3^msEDlC0eZs277*A*3$Yv$H)UP9uLyBW_hbe18eIweuy_h5E7Q&CZ{Om_`!A%T|U z7pEGm40bdgAV&AwsrblKy_GS1!%X8lcm8Ll{y272+~YlA!gVT0L+?naJ{pawDcl>1 zh%YA&I52u4AzNttU+-@3>J+)wln^;HWN~1eKmsk5_$(g$SB0Xat#!kP@rNk)I(W(l!zp?AHpKjLfUh2c}7mPA$j5>Sm_n@Iy z4(K)9{RG;Le@=YxX06e)NJXy3+M}m0244wm%W6zX;mw8IJ#wv4dvwH*ocC%rT1qQ3VTcl)kw8oGJ}+B~4S!yk-D?!>!1KVi|3~%J zVtVr)B^;GQ)aZ%)d z<(ZNBu~XH%4FYy}>TR#2?JJ;aY9!S2IQVUWWAc-#no%Z<;Zf*MB|<*=#bx}*pRML0 zN<4SC8;kcj*7CDI^l_@Ow(Fe>!Na?j^*lKo$?*9Dk3w30UFFl8n5cpW+yh=m3MAr> zYISNYuexS{Q&j--ZpX%@9_}v+Yz$#&3y(tDW-Z-+d(ak1Jo*oTN1<<Oqi@aP;^A8xRoCiLo zpze}6URVEge5o@>3(yBM`~{yTu(dYp$}pRDzh54G_HQFt$_fLF76(f4$kG>`pK1*^ zKJClK`)@tt+|#wUapzJI9;2O&*NM}zYHhX5y8YrP_I254BPhK;ADs4*LZBu2uIRU+ zTK9n8JkgHfELRn|&Nnz4?^Z(fftGAmet(cwYDIwK(Z~@jU)?a{ehEJw^!mKw11;IC zms>j7<=>O(Pp%YW*mk7f-yLNPxsj5ezkg1ihi7IF*6x>udez1YwG+#S8+=ttUTUG; zxyLrhI94Gu|8;H&=M#O9@gz1Ie_NPK;?Cbkv|>RA_0G3jGCZoPy|3})P+DH=Ob%!A z_nnPVc?0E4k!vlFafZJXJ$I9_6fHhyhI7Eo#+FVLi&@%BYI zXU0Ff@sYOa9-@wFMLA5F3xgAM)LJX>+;T%x;gL0g>(CpdOUC89+DUokxyHC z{9SCiZ=>0x`=j}w^S$|t5h{U}%N?)>FaH+oT>8%c$U|DqOW_@nc*+!11-tC zjArxPpNkjOC$Cq}18MYUvv$fd*L`GlK|SZ?(JbA7P(EW=D9^IKtrLBy#O?Bm-9 z8g2Ai)`!oS*ToqiIvS@pcjFoMwsD>--_7X1v@g#xtDC%nmwRMqLD6|VJ=-;6CAx+i zKg3`2-CdkWuYMD5q%G8k`wi(SiASk!yL121HMrs9NLKmEXk*CrdVKYFl|W1K&Cm5n zHnnCdN6%C?H`+p4CH8l#=y>w>TyS|Kl1104%9C#$%lW0O&a1E6@*z3%@nd=>=O4$L z@SAf&dC~ouWUDGXs~0y>RB`Y59LYqYLAvakib?UZL|K;SPQITQRU|R^U5Jcbc-xXU!f)WuE>$wbLUI$;3;yqEY3dY11-s~ z_M9xj*0nmTHeIa5yTY>fS< zE?=6tv%DUbs3`lVJ3}0)Mn^In;W(~TV%N&`4pI16P_2?GfukL1xms(_uQzGo)<>S` z#Za--+|pr3UjEwd(_W!W69 zzSbZ-kYT*TIH>B9%@YfMt zKWM}9tCBC_vt2VM_U zOX^5$5!rxs+n-B(`!dOast*aYBs0nH3}&wO3-tFZRx1Sd71Ht<-7Vt2?DP2S439$Pq_!%q zxkpPCmzw?Fq^2@oA%T`;eo2pMTJ-qoTBFmw6#_>L(lU$nPQkdW#h+*gN_Sz^{iE6J zjgdzDXvaB9o#F1WU*oPWeW!JO(2+G2jd_a?5yp#S#T5cA$yEbqncxAh`mx0q_G|bH z_S+vb8uIkB#~OV{)spj--|A7p#=_n#^5+v8o*@!wNoK1rIT-xpXMc9?ScuZQc$9h` zL+a0uJGP-6tCb^}avn&ity&RvLEl`Z4I5i(wT5aEdqE`z_B*T3X_uCbKk8882NGyW zesL<~xt^ir6D|LcnhbxzSc2C^W*gpLqsOdDqq*u1V5m}2y>8DinEyGbyD{l#{FB#- zUBks@=LoIFj@b^>)OZxq@~PZJzc3br4 zC$9ECxUGMFCmuC6{cv8&mYQe!+b&zR;BhlghY{a{>TQfJoBSchnCI zu71C((kg`jOR{$K-b`Xm#aZ@mVa=2$79`YG-MLUpU)P|u=lji34Er6QMGk1g`T6fQ z48w zKM8lpZV{)K#OZgU91MR!0((TN*Y-KY?5zj&qK@hekHX$niDciKh$C00>#=vfXxNMB zPbHeqy02eh(R%yaO%xxfme8M^sqa)10cS6H`V^_45U6pHmd_u{AL<_uHge}Z*+n6C zHE6}ZcV%|sLe0Mo_8YMW@#wzXbItGrcIMqaCwJ3 z_T}Xx*~Ii~6at@$khWPbbokfPCTj${Gi{^&WtIruYDirk(dxAFRD`}|zE9*x{rl_T z%$B{01MSu<8^Noct;=iQUHzZu+KP3HiMmcFn{p+ihQypxqxqNRb@=;xtDQ*8=ksG_ z*glJ$O=vm8gRMdWE!nK^UIvI9*=n$=YjP>)fp(FWZ+KVyDDwBoC-TR2bK|o!66jmj z3;z-+j!(H27gO!;;6Dz9^W@k1@=@hH%Gs(!=IfCnb-iQmfO=R3EJi)DWL;28c90+u4yoe@M$cyd`q56v-Qjba@xJk-+mn zTJGR^pIk5cG+Z42wT}H<>2^H#4}-Ym|DXxJpmcbDV}p;eC#+l{+n3uhH{vTB>LM)Rp0!flt^tciF5hN?*}$U%jS3 z-P3@fcO=k~TuEJdPk)y(jmYw%xRNu6s_s5pX$^`mTeT0m(yBD0dh~OMe8MXuo)kns) zi?xx%>x$ooq-Pyp2N^#VnaJm~ayr9S*^Qld$MHU^3OH3Ce=a@jn9w7GSh%T~(keU( zX_-NHwx2fe`4s){jQtt@f@>t$3$m|DtcYNC@Xxs zrM~IDoo$-c&L~m27vI*ay3!KWhyP#2*py;27od!TRqETph`rsOXWCI&CBTwgTk_Ok z|D4Gtrv154!{PC_fys~kXBdjdi2<@jdJ!AqQN3h)av$z{cS&< zeaI4JeGh4=UVCZm*6~te@0JpdCbkYnNSpqAY$}fvX_a{2`i3KPP=xp#ddrRkwghRL z_1Ta>hcP`;{OT^K_`nqwq;1x`Z+>))o)jrkWz6ijdnnv^ceO8{C}R{7=-X!PzH^oK zOQ%6%?)}-0O_Rcn;XC^BD+`u5acvQ4n{{dDqGGpJOPu<2HWqy#trA6Ec4Pd?ANuKl z@pdH82h#F;EPq6@m{op`qxUYwA%W{jNXt9wX(U@EhttFESL~zwB8(@_y1bX`v=a%m zB=g&=`m=o3Ld2VyRor+K(#~fxS9NV&UO;LTxk5jzy4JjX7z@tS-+{m2sv267>kMm0 zItqnFvUY5N9SQV-v^?|5j~&)p@|{78yLPF(44>-5qmY)F%YTk>=dBXSdX`<`z!f?q z(2~r|Tvv+u?P?&WEyHBbngagdhZ6VBg|&F_{|>?pgz zfwpkvPF?qDcBC|WaM8|+j#{dm$IIQFjo(Iu@R0ZGoMX>+GCGc^!yni>%Inc%sr)YT zPY1L|>r2Gz-Ne2^0(-<}ZImrayESkCOE+$aLZDrwvKD#G$Y4gYsUjR=)IhWf?ky$Ym{!>+_;mmGDku^^ZNxyvz^6DXg4}^b|8Uoc#)R- zKbkdV+dIm;s^)Gx61d)qwA`ydx+bg7im`(ehPp#?w=r&=8f#b^1#=|OlFd3~Y86(e zS0Gz_WV0RDSdl>UtQwq4zEk_DUQNXZt~#nje16he`SvRG zRqJ4Bm;3q(3G^o`56^$2uL-?)`lA%T``R(tuHV%z#+tZ)9}v2y%y-)KhyTPVLvR<@bA@oy%!w^arQuDv3G zmZZwiM~j@L3hG5~r-{Em5`ExWA6k+s{NPA&K5Iw4;M95cxjn=9h1jY_??q)f5@<=T z;=Ju8j)(rHZ!X-yfkZ&~DE_{T&FCuU5~O8z+pTt@c!SUSVp$^|kHS?)m6&Dm7cH}d z2qS!vLZCmSZPtyKBE_n1BDjah?!aHB%o)jpuBGFzdpvRCs-di;^J}E=&#^Z6kA_2) z^FRVE$^AG_BSiVZ5%vaGW0aL`JWBNuyfQ)@{(dp;(CJGKTzf?V*Q{;UsjK>mu90p% z`}kiR*b-%xQ(4)rHLRVuUNM;%_vwxOV2_?WWK;nEXX>BI3N2c)S?}!)5t9N7iBe0J zIgr4$Ui2q-bCfGDdJnUSoqudr2wb^ETJC)f3=@tq4Lt3eYs#7>uALxlv$|eH3Nbp? z9%{|(#7|!Q;&VPQp2l;t5N7%)>hwDNPNs#M@-UlI|K{j%hFHc4Q9nCt6Zv& z#RH3rBdfSb`DcI~SBJ3eXvt=^wp^x9Ei_mRdOTh6f$KA9Not=Xo8tP(_1Eg|?Di6w z0*ww|C-Hsv4=8I(s*kxJTgL2mM~Wu%Z4O+AK_6&I?nFAAS=;e?wD@>soCANsl@PQf z-!646%zFJ^TRdm0+(_VaKhko4)b8x8-1(oyhBm_;KPLahC~|)&e^mL96A83rv$ich zh^4#~tglJ_tA;Bcn4_USc`eG;f#pyAR&e~hIeW5r09?I~k8VNbtjsLr&UhAu?=D1Q>>zitH{2i6}3-*G%yA0pmCEo(% z%;Pw8_c#4v=H3c{K9IIqSHFs2dpezTkGr?WgU`s=Li8=~s6R)uYyoLKng7YF;WHUN zrQ(yNT>V%W$#$ju+uh)PEe9Tj&wwhiw`L@Jm};)an)a9-pM;S>OE&A(XAx{=v(=s= zYqv(8S{r3?l%>M4ad4J=k%+BWh_oEsuR9!4Qsd`_s`pEP~-L zIP%eweDjmDk>^p>k(%@UaE8|w$1Bn{>$1BoJgtI9X_h;~8QvdApe4D(?NG2|L;KvC zac8s=2XP;yI#Z9eE%bbi{nOFlcsRrOii8?*GTeM$xxeh)rLwP(z@3vw%lhrP=Lhz9 zSXtAlcM~H$5@<>8N|`!&PXp;=xg_wKBO!Y?(UQ&j=213J;hr}fwL3&81RjMW#%A4n zeS2V=gi(T{8pjge1+sVh{Zpk?0)eeU0xiiqD#QH1+>%%+&jX)K@F={yWz%+W3qXh~+0ul=m``Z`j}m!gGoN8t!p?~g%e9!Iy9KK?r=B7yg&%#(RH zuyVHq0%ric&vC|(-(H?NBYLDH;zv8$LIN$x9IqAWcZVkU!21AaAN3wQ=U-%3Ta#8H zftKX{^UXo}(yC#iY_4$6kUdk43H#?7ne9)VyVJ;87WRck>3?hRQq$)cL+{Nq>J_RX zcl};2*j7|8kX*EQyV}vKtlaNOV_TzP@9G<0DS z0xikP!%~+-SboscH;PgS>{0Y5_Z1!5q@A2IP#Eps*|8UqK;QCTTB0pmR3y20vUj;-+E25*<0{?<5@<=*vWVHF-I_2^Sn_7~Ac4Jrw3pBm2uG^Z z6TH_W-UkwB$=fQC&?*lSIF^u>xj6C*s{`XkIC?j~VD}p^(P;K`wfxFZN+%L%NnZ2U z<;BrsHrBmyDi0EP4Um@a=#$;kZ>;Ic9#x97W4|MTmgMu`%v<`Z0o}yfEcG3~HCw@hxF6YLOYd8?~-wg2MIa)xbOZzTDEFLxESHgq`eJZ z>E3sJ9>3Umt#7m^5@<=Tmt@?m%a8pq$3Ms1vhCb=*P;)!{_OQiZ>x|%-!^OCevz!Y7V2LQUNxj`)`3BJ#F45$vHpjyyGQ&poew@em#-bV(^=>51$^h`#XP>JGhCd(*G`=4 z{o7{!{k*KcpFfaQe7(|h`sf1QsM9juzWEk~Kua<@Z|*DpoaNCkm-2HUae3VwK7Q~z zuU+&fS8>AYi^9|Mh}!o?c;tM={dR8P+dr;xB7v4<)zf4F+Uu92M8_{r97xDnjqmHX z#_I!VndRN}iuPb=55dEW*w3Aq$TR-AhF{vV-iZWSlJ9ZGMzRklM|duGZ0trt&d&Vn zFXhhgyaMU0Cv|n}f06&4_qQUPAW~(v}h5_5>e>?>b9MhHor*Tcty*5(yujFiH|> z@0knVnaeXYe7%c=?{8|A>|Fu9n@GgRCEpeF5p?GA3hKTq7$4PqqXY@`?HwgLjFLn` zjts*$5|Q?fMCKcba>Ni+e2B_;`wzXT(67rPN#MLDfbM8c3H-Csp8mvF@jC0wB3WY@)axR zZPi1^9FFw=KJz<)S-nK#x68Of|IfY}^Qw)vM=xYv!ZUo|?)`iFnN|Ov-d*}nSMRks z6coy>s}_3y-aKpE|A%N>J-gR#%%svB>HmEm8$XTp68G}uY%6nT51kIdsw99!H zX_uvZm1_2;ALagx&ROi|r}{a&l-$be&K;F7*Aju2To#Mp9edWtkz!P}-f>9aIiWw7 z<&SoI*_Y>P@RU}O;_R;39*h%6pe2_ju6JAawN(+q z{o;Z`to?5F-nGA`Uc-@gS>~-gp=V#wUW8v*tYMr$0)4wIYkMWrdb=lUYnRBc6+qij zWvH6rh$NayV!b5LE)rJNn5hMcjgI@zK}p=fwWY8rJJ(Z?%CSSte-SI3L}|Hl&IF1)vYyKE3t00hPnim zCDJa-Tb@dnXP#{D#c+Yq3kkF&$JNDfZ|`~}i3Dm~q*VgrT~flQmXg;l;5fYC=nuz!rZoq#Cd`dyuW%yuAb)?jV`4U&sOFX^h`!X){0g>;EJ1{{4|Rr?Xr9s zo>P3+QnS=I^9g&^9DGw3H*Y(34o3nlxh!+8+|lc@)Z+6GK?+gEnT8M8?B=g+3l!RA z(cYZb^IuHGepto?=?}N}85s%mEu-_3q57z5DTM##rV5d=|8r-pF>c<_Z!t&OWqD<5 zq&KOajIHR~!SdOXiRGIg=}0tP zmBZV0L;?#1%Y1KV&U(2|_t(zRf(<@Lzf zwuy$slh};Tm!;zP=Ftrl+GV*owHbRYugAomqqGJ;nj$MU^vt8*mKx66|K zMSeC#-XGOg_rAoUjDUUC5Kan+EzJc|G=IYo-uczNO?xO3S`F@2}7ViGN7^W~%hDxlQ_=_?{gE$s(=MYNf| z+7J4Q2+~(%$xL4nL279~=qsYl^woaQS45D$B1>laiU?9m`$1n3ZKki}QHlDB2+~(% z$xL4nL279~=qsYl^woaQS45D$B1>laiU?9m`$1n3ZKkjGfxaSw^c7h$(^o{0TG|i# zifA)^wGZ?a5u~rkl9|3Dg4EJ}&{ss8>8pL9uZSRhMV8F;6%nMC_JO`4+Du>V1ARpV z=_|5irmu(~wX_fP713t;Y9Ht;B1m76B{O|R1gWKcps$EF(^vaIUlBq2iY%GwD}kq_4=5 znZ6=|)Y4wiS45lXtG%GFh#-AMmdx}O5u|tag1#c!OkYKVz9NG368ohaS45D$ zB1>laiU`tI(V(x0Hq%$pps$D^eMOec^c4}LucAR;5pAZgqCsB~LHdd;ndvJcNMA*R zz9QO8U+n>XMFi<9vSg;Oh#-9x4f={`GkvuO^c4}LugH>_z9NG3)gI7SM4RcWJ)p0M zAbmxa%=8rzq_6gXz9QO8U+n>XMFi<9vSg;Oh#-Bn2lN%uX8KAVm8h?XAbmxa%=8rz zq_6gXz9QO8U+n>XMFi<9vSg;Oh#-Bn2lN%uX8LM3=qn;fUy&s=_?{gU+n^YMYNf|+6DTG2+~(%$xL4nLHcSJ=qsYl^p!j+QC|^3`id->=_?{g zU+n^YMYNf|+6DTG2+~(%$xL4nLHcSJ=qsYl^wmz#S45D$B1>laiU`tIyFgzNZKkhw zg1#bx^c7h$(^o{0zS;%)ifA)^wG;Fe5u~rkl9|3Dg7no+&{ss8>8qWfuZSRhMV8F; z6%nMbc7nbl+Du>V1bsyW=_|5irmu(~eYF$x713t;YA5I`B1m76B{O|R1nH}tps$EF z(^oq{UlBq2iY%GwDn# zh#-AMmdx}O5u~qnfW9KyOkZsWeMJQ6E3#y!uZSRhwH@>o(PsK;JLoGSNMDg9Gkrw_ z>8tIauZT9&SKC2f5kdNjESc#mB1m6t2Yp4fnZDW%`icnBS7gadUlBq2YCGsFqRsSG z6zD4=NMDg9Gkrw_=_}dxM14iHnZAkweMJQ6E3#y!uZSRh6$Sc=Xfu5k1^S8z(pO~3 zOkWW}`YH=_?{gUqykw zBHBz}MS;E|g7g(xGSgQ?kiLoneMPjHzS;)*iU`tIWXVil5kdMY3iK7xX8LLy=qn;f zUy&sS{M4Ra=+FgSL=_|5irmu(~eYFkr75OvMSKB~e5kdNjESc#mB1m6t z1ARrbnZBYuN=T5tB1>laiU`tI+dyBDKN;2PFY-LS)1BR2^t+CE>`0Kl^6ifbYuL?E zvQ`iAYde*wxN@CyS*30KNxg*$*<0jK=C@Cu=Lotvn0cBl)G>n}3H0r2qe(A%d%ziRY%6i2oZestg`Q+A7)nG4h}HJTO}oGd@*9mblel$5a%5howo#fuuVJ_ra;7{ihyT#pAq; z`RCo9kU2p2GwwK3@4>oFYl+_f<`N$dj!ejBpjie9b!Hg8(qBw&93lpe%Id+n4+(X? zs`{;w*fut+C?i%lkl-h)@bf*l$_UqxBki)>9-B+tykB3O{PCLu=Wry@l2jS5vWX%s zs)#KqE+yob(5(MSSozmuTlty!B{|aaj*7C0ZBsI{E@Mt7WW7)44 zQP%xY!w89l8rAA$I-u8i?-U;{Wk|>nqPUF&T9TUK@)-Sq-)wEdvpx#(L)KGHPw+OL zF<)Mew9N93h|rI9eW^9t*E%87iS!2&s%Ge4G>d2Ar}QFcK#PR@D^f*Bs7fZ!j7A!7 zsOwARx5-hfA))H4-P1N|era+tcF3U+RkJbYz!*6vW~s!Hc3IA^c&UB;>u=Ad{T&ms z+emXFfxcaq7b`AlmTdLJ>)X#0vh+v^BcZBSBgHQ)>-syoxHd;2M94E%wR#lKFrpVn z+GWYvY?`*B&|ud2hr9{7gQVz@P*wQS6-`;4hsjxn(jyZx8tFL$3H90F?WE2u`s7o6 z;n6}4eELB`ePYSJVHo@UuUz^c^}8fwf6_A<5@^X~X|%Wkdz7ve+uDDwLUiib!1;Aw z6hCl!7Dw7;$=anoOLZ$yKjwaski|;RmPn}2gRA{sXgRai6MIKq((#EG3H2$p=Y+Ld z>7A*>+wHmmJ8%4M2}jyxVLN(j>$-Pl8^ccN_~ecRT5?%j5wo?hS7Y^> zyZR(#F4KAf5@<z zTEx?L`9+U==@nwyfbx9jec7sI6FJgy_gS4XB4kN%{oLm22@!|Z!jSmS`qg|tv7vcA z(XHXOgor~cbV#VHYC*TSI9RKYs4+lOh|z1R^KR94@O|^Ta->}rzm|iab z#3)*6L_%F1oYAv@u*trf*Ck6rjH2~VB-Ay_Q>(c+D*Ni~fc6R5hD6jmScbQov4el~ z*ErI0Z+PZmV%f6ddedQ@6Cw_+<03&Z%D479^-%`#PWF}G=$Q!-hgO%7P*;SfmrN&0 z)Gj2R<*+2=auQM5FEh_}ZwF7`x-duDW!bpZCcb^ms8?K5CLv#xR>_f2SGGF@Y}Y5r zzWRHeUqZy8wR!h#y}@Iz7{N@|?$t za-?0B4BJDsrpYqtEAtIch*9*02MP79%=@h~wAQk(hQ(z}h*9+32?_OGQL0ZCwpjMn z!h7izVr9C_&Ykyn@*F2?aHOTms8fd7mKN8==IRNV}!>&20FS@O0V#2(1`>eah>2~mySP9mYc zQQXRaSpCDXBIvad4av?T2R zAmV$w$kd-yGWdGzBTO;I7H}CEmB4X!WllS0mzHHi3jyf}+e`h=bHZvp7f@{76t7^kv;Ajf1qW011kNzP$iR;~)_fW%fZF zq!yaRLE0aI1jRw$o`$4xkale#L2=Nx(;{gcB!XhpK8ShnTN{oZlLbEtXdwY7_}ebAhpme4$_`DBq$D&C6^^> z9HgCkNKhOkOD;>&I7kG=sQnNJsfA{7kaip*L2-~QxhzTJAnk)hg5n@qa#@nbK_Vzd z9e_AUEi{XRw4W0Rii2cHz9mcYJV?7tk)Sw8mgIV3lIKApC`KKCI7lrti-WXV772=j zWXWYo8V6|)FA@|7$&$=9Nb)>L1jVQW5C^G+W^s`AA|pX@kSxiHSxKG;X@@it6bH$Y z%aSw>52@(_s$+vtT)x5E25k65bStzBTSVUD-kf1n7bxmBBT=UC_ zCcCPL*@xRFta4K279`M;%QCjx2+@`e)58v4OxV3l#5-Fc&)+4Ew|>xwBQ3Mr3YYPG zcpf2sxVOQ9?;4SyI7pV{NIcCv8>1t|$G{SHB=X)Y$iME3 zWyx%ckA^4TJCA2_^D*!8aHM4(z~kKR0YUN`gkrfuq}sL2`KXVZZ%{}fo0$VF{IbWo+Cx0cs-S=*{ zyIc0u{8WFP@3^suD@VZ_Y?q~bSSfouSzk0}?@@QU)7PA{Y&su)XQ@JRvubR1UD z3A7}uCw=TF2KCybX&2Y)l!ZcdgYYdo)+};aPN#dNS1VPQ{k!(EPFW~a1qcbnlEu0> zg;-i4CA)dKf%*M?S>uCKZ#u0osTp=*ZAT9Hj=Hzmu z&G%U=^{_poJXESvg9Q3^S&r2E?CJNSKI=Fo7o)6Fsyc%NT9T3T;aR;;^N(6fTZlqn zv_#rv`L)DZef;om+Lq(B7}k(M0)5N(=U>k1#{2s0#Mg%!R)j$UEy>J*)WPE7hGcB@ zju#4nQ6FiUzgyof(#gKs-r%){6=9G--!d=!udE`zuROJCXjtC`3A7~huO{{rhmOT) zhXxIDBY}DcX_sa8g>ccmbP9cb#iw?x(1HY7lK0@ND&kFC1-2u&fR6Q6kU&c=%P$9O zieihBv%kxoPKat$Jp~E0B(ry9U%i@nLZ3G_qoC+T1gcl0)+CPLqVFMzv6opg8EO!{WCvwWpe#ebNodlMth*vI!Ct)qGV|E-tH~>2jvdo~M;U z;8PLOGQWLT4QhS0m-- z)|{;C-=DSUz@iF4F@v4~kv5BJRHp<9ifXt;y5j_bL2@zfN5G9Cq@63))^wo)$5J94MYZs%08_|PRcM+WkqU@c~gXmFKCu$NB zL=eIL=FV$pKd)ov{=WGmk9n+lzTRcd%=^rlbI!GLRpRS*DOC--bufWC=&2~;&_-;du{G_ceyV$!bY7zF{ zU;=f}+4tkiaguq&>RA?kcSTB7!%iAZu#z=SJuJ$cVCAaesWAe)Bp#tY$F5 zBlJvLGw8~-GG|%2TD3+?se{-Tg9%o$R&6C`G7DMlYWKcLDRmILTrhz;=mUeJ8GI9D+N%y?KM5vK2c5kv+;tGULok6l=>A;&gCJ04@Lis1 zuR4gG8<;>Hbast!*Fo&Vzy#``vj>E`4uU|H!5jvrz3L$LVPFDv5K3Nk5W6Zcfja2y z=-{q{AW&s6--BtdI*45rm_QwLc64ypLF|#h1nMA^blF`8L7>WDZVc02br5?bFo8Pg z?9bq?gV^za3DiMnw*_|{1c54pc|=Tm)j{ldzy#_bl)UO7_Ag)pbX8#3)x2eG#R6R3mEz6S0(h@AqMKpk{;DR9?85U4VknaQ+Q9mGxn zOrQ=ryA-(VAlCgefja2yIpD5?AW&s6iY%e9fV&Q2 zg*_9fgU;%HcO3+QDubEiOncQqtgvSSb*bk19fXos9mMK)CQt{RmG|yC2m)0GD-@Xas)Jbl&IIb9v+~|u2eFo&3DiL-dDTG> zs4`g1!L(N$#9DSHPzRlL^zJ%{mFY~N4mzvi-E|NIsti`XFzrx`mgCJ04uu6z&uR4fz=1iatI&0kBbr7q@nLr(MR;;`0AP7_$tf*qzs}5q- zI1{LY&Wd$+9mE=NCQt{R_2;X)bTc~M-Ksd8%izVp$l|5j{}*HQC&KBlex0HXV; zARVXn_2p&j0j`( zJ4cHifV1+x(z&Ak_&!l;fd^TITrq(jfU}PN`qewi)j1!jdnfKUc-1&Z%Q*}^ea0($ zw+%9vug&OZg0sN1^^JZ&tWvn@r|M66b}Ae>N6WPJ9p|fERfo?Xtj0VVrEq?j7Jt|B zZnsuO$@ktfhm5VH^6FluMdG8h-Hmyl_gDK@YOU}ucQP#!39pwlH9)7hB6FOJ z>|VW-w##gyH7tC={`=Y8O|*d2AUU5}dg+v)exi;*^g3|JCfa3gDbN9__qU&$#i|_l z&3N;;Qn=y)TWiI=#u^iBJ0R_j$ZJ+J%Np04)H0c1ub2)7*Z;_}y$<&!voY@;}-g_d^|;wW}mdZ8l88D7HEILj$><;0a~jaXYIc)?9khaeEtvdMwj(( z84;Iyt4vHAd)Ow%Z0ak}0crB9KC`wF73^8zeYJ6p{kB$2R9}q=RtiW(yL@bHYW{oT zqSHf7w$21A1*Fqu5|mtro&^id>?05y2h#y*+?I-_Hva1Np^Yk-2c;dh@2kg*(KgiB zYkN2${b;Rn{XO>Id-NV{Mc$!9Z{y+2iNQk+hX}-|O@>V@?KxVY1JdC8Rn50zp7@Ps%k2n1Nf@9#iTKX;>OsE&TE?=g?c+)F23e7pn$XYek@j|C zMt_2l@%l2G$TD}ZK(JCk`l8ElGf(80#NC%81!CuxrMB*vgCZS}%;v$$)CQW*{#9s?JLq$G!t9Qwd44)&nMc1e`ncV zHM#kzR%U;keSB|Z7c26mdW?CaOS51fHItu-0uw-2>ex-71JaCxF=~-#KPE! zUDc=|y`1>F$4f40HIs#NSKxQdx@$ zwjGc@x%PLk?dRWCS@Ql!fneX64oKTuXE0CgpPneIZOk)wezYSuB0klIFWqE&6_@;p zHu+Yf{r469L)W}U(^zA6kxYr%PYhO>FiRxb#N;y~9gyz5mqA_nR>$Dve_E@o#RMw_ zq<`+$F&k)~2H&Wb-`uxytF7B%XlKC__AnqlyxCi=JRz(9`-9&bOt1~M9gwbNN@sqR zDV_h!8iCl`j`E8vs)?6Q@E=Q~z2S07EQg=%=Ox?QzLKc>?{wY_vF z>-U|rWw8IAugoV_Z%Ybw$K>2gFlC0|XsTI(VsR3_d! znZYKOtoc-9Iv{@X3=!-Fq{ctQ`)2HI7;LmCMrAD~ zSScWFUNu^su|Y3uOWM!yVyS54bxfZ6cJXLcMtJ2ltd?ES=!Hh-QL z+HboMdB6P4t;j$AyNvlT=YZgbW>r)sT1|d#6LTiE66k>RZ@Or+j*;Y>9rcc}`KSME z8>I%f(wJbSfK)#H2=!RYq{M7n%PCB-Crk&VSSeayTMr&_sH|^uq7e-l;kMGdOJTa<&wcW>Cb<7bO>Kx#2@gj(SDT*1+5A%zL{gz113 zT-QhK@$>w|jz!igY@G>K3P_>7wXD6V?B9O9+IdoBg{;w9Rb*^AiIq04m-$G-8y%lXV=wH*;%{a4xK(LZP z#4_>lctxAebfK_7iv(L|f|aaYHrvb%?yxG`N4t9Y_)cYlm2^3OZYg-Od5r1HIo7W& zy=-6eYq!&AOt6yRRRp){Y+2LUbei^sHC7S{K8xx5gWt1TexZiVUTV8c@L8_=BlV8r ze_?{}N6{-L_$IcCB@(Q~Z>;>5DiZ7o6Rac> z>=o;>hXN7H1iuwAEf5h*@Y@#CBEi>y-ymJrBlSM#2$^6d(FPOj3DY)_YJ($Wf|W!Y zd=0pl$$e3g;48=kD~SY;$uPl6VjSFiVS<%Jf_oE8u#)H%6Wpg}`Xz!!gt%uJwk1aJ z2n6>_xCbEG;Cqb;RuT!e&IBu2&)rh9gWu-1#>X1u_H*BvpS9wX+IqZM|Jl8aSnhc< z!Ahc6oKb!s2;Z*wdN9FCq75e4L#9Q7eP@D|M1or!(_*`NvbnmoR4&Ghyg$s(1lJ{| zb-8l;b=%vFi81?59`5I{NhVlH^orm0xiW-*`tV2r6RafKV1n;dro$5)6VoEW<1kFH zZCyUlE7q6LJI2iOiZ1jSm|!Jcmfk+-|8s4$`9-^J0>Nj6kDN!H_$*g;o%na zrqm-L$j@*l*tWGd?zJpN^GCzY3qM>E2!7TwEnW|Pw)6WSKj%e)>k<>JWD~K@`v;FF zFu_Wq4JJ4XOk2OYP_L}jV?5Bznd5 zjtN$>b|YzZA^70!k>8zG zD5d6{wKuZE<#XDutMjz6J<~@vK7U3_+i|ECxoTfz!C~jLKDXvs?>JL$mAButXBGHR z#aZX=vzTBdYfaqK_snA-&r)u_Q6`1BY!mmt+7o$p@Fnf|`FUDW)4h>7E??5>UkTZW znO4EJk$-SSfna+~>+;1>-x^nsbX6Y||4zYKu)(^G(jAFhzwwH8{O}yhMyE`_8|q$b zMdP#Cmf+FCvsX+$s3{z^dGZE}8LYO8E=KF&VYS*B2r$K3TZQ8m(`>t@D#sn+r^4fhqf85v6YE=Go3KN&-meU%{ zU98=!K2)Hs)v|-@hOS<0_DcCxoHgG*iwRcpiaeeoPoT(=1+U0KAdYy79JE*D@f0}- zL=Gjd$Uz{Er4%`6ugH-fCJ;H4ydqylk$*>#J8`(LIS53)j3Ni^75OrX90VeVl2_y) z5XTCN9JE*D$PW{U97?#MwP@|6@hXs^guQsf{IIh4F22Z1=&P~@P! zB1e9hK;%&JihM0azK$Yy;&4X}0+Fw!$U%EWzLp{ffyklc6*&mR@jXQj+ADJ8hY3Uu zC0%~AN?rR2S8Z&5yAC!v4}Z5bUlMt9`v$FQi8#AbH#z@MY}ysx9pDdp0o zJmEIjxpEUu}3ba?Q zkWnU(D=2xjlFiggexz37iQL^vKp(<-ESnF+~f9l%=2t{*u3=!MGgXyL&+L%-{bSg zTW=F9^5Jd5ArQxLiX608n_qTalkliQFL&$EnoF9oj2$L3>4hnj!~*$f2amzeFGMpA3Gi#%+9>@&*Ya>8H%v`U+>X?v3ZzZ;%%rgSJ*d zw0u|juk}zfTYM3Pqh$h-LrIrEk7#bcbC!z9n)1$xjDkLEXP0SRE>@z5I&!0FJdJrT zg}_1(YuA)G|#zor()4FV2Ev8mZ zvq(8{w~YG1#jBA8zrCa_wMK_PT(ybS8LmbqJiDaz!bs6(*r*-l*nln*tfb3hPZc)y z+#9TF1FIX^Mz4*0e&do><-MZX?W0p7*H^fx4IWWY+ZdHTvccTTTDzpSRvcy1B?Jp+ z`CRRO>!?5kP8_gW`vtAqsayiB%RB#;lrqLpHQ$Cx0`c37a$4i_7qyy+Z8fI7ygEl- zoh7f%hxG~sbd$&{&|Y4FfLCY9E6}>!I^+B1fofxoe0o_kGPo}Co9m~wTvo1NvdpsSe z?szdN<$8eVlTOlZZa=1-+MhQiCeXTEcf@l47YC!&;~9#v#u19+FvW2+C627=6N1^TcC|RAT{%Q3j*E{EQ5>Lk`AV5e{@zv`(^KMb z2zYfUH4cZ?<#;QO9#$M*W<(rK&L5^YK!oCOl&s$qj{MR0%hxe#-uRAo`k?bt&W92@Stb{}PTFJ~?T7!*g4;xNO;0RnL( zQXHVY;sAj-5-ASQUU38|4iJa~N?xsfD-m1B#+I%(tV*%reqMoq z?sD6A^ie>2-5(%ue=MiSL3`aFD=1eWaDPC_>;70u#1gWxBy4teIX2v0%_pzskyrD=dbNPO!ddWY zVOXy~z$@5;SB{M=4~rNhwhvZwY^`PY$fjJMYumWHVo;<%YL2#}Z>g02Zd{6uk$uYs z)d7votlwt0dwmX3_m?bM#ksS!hJ%Z1Oj|o_^xojlF)UimI`v9QpJ9$2ITNAYprfSA z9}OMtm*Pj5XA0#sLcTXXkNziGANr+g;|tdN;05iAUvgQ!@UM!FHx6F=(){=IP{aSu zlE_V4FK920=hc`%uAClnjqaU;gQ|aFKK#%S2-amgzAeWI1R+H_7&w33v-7YajYPWBqpmBg`gi-Vq370X<}f-VD11l% z6Shx=&DBZD6$r%P^r_vs0>Qd$PnS;)8SPJRUBSL7SJ3HMhR$+&lc0~KcvZ%_f>*37 z*faGC9!vGgA>ftML%#7@=ip@P3dW>fK@hCV_N?C+Nx6c5q+G$xTMwmPL8q4q8%|&J zRO>h=l*>#{v-e}=eG#(nF_&^YzUkVx$c z{W;ihdVNoZcM9gSo(u^oPX;48k=hjqJQ<+m)viFGU7??db_LqYt1aXe`g8Eg>Giq2 z+CpA|fLBoR@(KjJLO&5+f%fuhBYB1X9K3RReQvKdl2;($6_mWZ0s*hkPlQ*Xy}Vja zUZFn+ubf_=+pG2D6$p3*B`>c)z$^3<;T34F){Y(-S|560XqBsN+g^2WwH*f&h{New zx?4L4)@9l&SIa3^=$*la(}#2CYB}W!1abu>uUvsZuF&g4u0VTvwS>Gv?+ji!eK@yQ zOUNq_@Cr&^UV(sD==H%X&|Y3GBCpUpgI7);&h6DA@(KjJf|8e4Am9~xeeepjmsj)1 zEA#;2mDB5Udo_=|0s*g}>pKHRiRvVB(4sc8biO5$8KCZ^>ET{g{3ffl@qcyj4lWXP*t!)9wtu##wl zi3hooYaK(LZP z#4^!vSbCc-P-2`wi$wlG`RudW)*GWS!Ab%VF~1{p*XQmb5Uk|Y-@RzRP2d@I$$pOx zJ-dR@SL_yQ{pwrI{6?d=Vdkts8I1D-KZ#6ua7D{MrDV#x0SL7A%PH?T+xAp4c0BHF zE#KT``m={^!Y05l3-u!8)U{= z1x+SU4R8lDt;kSi#8f-|Bqoq6 zXWYk~D-ehrSzy{LSLj(Vfm}gJmwQGux98jykI8ECcpLJAXmRZHuU5Bw*O1x_HP0f+dV0zzg=w!` zVdS3)IA2EH*#&`UF>1uLS9b9=lnG=PN?v_95Xcqs#=QQ zvI`}z?D7|IWE8pDozic2XBPyb-C_6P#p~hD?hgCw3lqq$^ZneNT@Z+NhyBjMv{!a_ z*k5~?Kz5|%tT31k;aUQbbcQGo9ldl$3o zev0m=TruH%n{ej}1boN00=DOstNoNK5XcpjymAEsks}Li&ns8>=D`GV1tqVR$n&8P zEpl}Qg^iu(-D9KEr#_ULU57wRbY@c> zw))h&tvBJtGo3eK5Qr9Ef!Ll`b`Mc@K_I(O^2#m<$30f$T!bE4w^v z4jDzRehGV@gFsutH#YHl{5bMZ@JlPZ|87EdFFZb$n%zxXK_I)%oV%B^>ky%6gVFdV z$o6#kHEWz9r!~&dJ$0PnSZa11B9vW6NtYY`Th0HiHO}zL2hKPH2;>TRV|(uGHa?GU zsZ1cdQ1ZIZ&)8Xn@Ayh8X1Bo}UobRt`BFZN$y|7Rl5)ialyuphD-iGNyOAXiZGYF8i-`3369fcDB2#$K5~uAtW!suk*2RhV_nBgML0`sg4dV(0)fsZ=?E;1OCe zyJhZ=H?rLBrmkqQ))+o@d}K@Ys&*j0u*L-2*5yqZ*BA}PRW%on&S^4%>w&Rermbpl zKPmXx{a)%fxwaW`Id?~9PQIf3v^JN<1S?tleta-C*!F%Wvr@4K0)gyeG@EJbmwQjo z_V>-%UM=5BG9PY@(8ecT(K>yWPh)~@>++x;d;R%~_A-0+G7Tnp1e$5;uC8Ml0>;Cl( zl@wl&G4A4HYnFeKHsQ%EVP6Yt7Hhf$e-UeraK~Fm6<+PY1S{$Clv9P3-PSDcCIhRd zc!gCEOt6ymYaYGQ8qwB__(BO)Q@q0Zm{#$RtoigOwb`j&IcrS5$oRgxwdz>Iua_0p zW-!4kZLI5I*%)WpNQj4xi;uAy2*fe6fptu}+|{!2mNjR)LaGg{m0==e!%=d_aq%(M zT7`%y*AJ!IXmZ|J9~FvYsTGHoY6B~gn1Bsu)z-_oy7(9?*+A?k8_w!8w+*cGVgfdt zHF0hmSSJW#57}_m_PK3f6&(|>;jB1x+rTPJ5Ie|*vvSgH18WqSfDLCoXI$iuiJ^J= z#p65rdG#uv7227k$$7_y+bgW2g^fh=%2~+Z<0KA+D*7ycqU*EN?wt#Bpa*A#;UN9W0wNh00A3N^0KjvY%C`m--We- zJs@BM1Z+Ub%SJreSVA@u!rH(N8?XTaHlXBXV*%M%L^c+NwXxVeN?WUy0qCoLXBkx|9D+YnI+=IVcdkT9Rp9u0C&+@~bry z@P}%B1p;1S6(!SNUZEoMdRKVmRA;wWAb9mE(_UVIfLEyPOuM~uYIsQ3i2mQ&=l|97 zm%Va`kgl_um}yO+^;qB}i+X~R4Di&1}@8>Kw5u3*VjuRy>n^dFe^@(TR~=wf|7p6yPb!R-|Y=wh`!(_UVIfLG{o zFzw|PdKS>-y&9YzfZHn&(8cZtOnZ3+0$!ny!nBuH=vhD)`&S@WP7lEC6$t2JHwvb` zyaEBQ&_`j~%PaIOpo<+y;FZ(MaC-#;y4X*IX)mupz$^4onD+7teG%wxPF;)szx{pO zUV(rvc7$Qt%PSD@3cVVpy}UwS1iIMM2f1?kC2p@kKo>joFzw|P2zZ5F4bxs;p)Ug6 zwUjHTC*$@C1az^-64PE@fq+-&2QlsC6?!kwT}fU!eIK`1AfUU_-Wz~vFRwtrEA*V0 z_VNn-9q2A2ubkeJ+ba;zT}EDk_VNk@yh2}#X)mwPLxNZF z@y((NHqmQm6OCzIZdtLkS?{xI=GwNe1oM}CXlL>JOHDM+kC@%r3%?64y*|Y3JfxGs zYu}h)C2K!`b0y3rqsp4ccFYin3WrwO*XEyV1qFN7KEkpbY#4D@`N-&E^2#|T*tYeX ztgU7#&$8AvwY#qg1ZRP1UGBf9fI2koP2=m;?+OIxifLVLy!2nC;>vpJmhC(2FAYu3 z&#_0(nP6{qxn1@mO7l$v)Tf^h7e*jA|0CJ+x4(Z-RLe6WuYDYou8P%m>m5Dk`y}&F z^^vYnc$oETa-~1vH`_o|J`qp=B-SzD% zYE0|$gZ6(aY3F1xAC9VM@|9wOm8@J1`AO+ClaeSK8Xy)v9MebjcHw8|LZP) zlka5n-r=SuUnwTowk{VwwAGjM^L*;2wDkny=9*2mjpJ1cYfM{v2>lf>dXLL#<|wL} zd~KOv+twG8h0BdI$%WO4OUepFtLFV|8xK}~pfRn>cTc`yw);50nY&Xaldm%qY}>kP z|GsUEUfNuJ9JRz?;$XdewvF%mH_@23R^)!Hne)fwGb`_JXK=e>f|YbRA!`TY*Xu*o zD_@rIF>!xPaofhcjAJyWt@<%Ht@+XWZPYJ&UGeh_ekNE+mk0cF$7q=pr9R&lukb1X zCRj?i z(d*s>jcMz8B$QPuCl52rCGPa|6MzY}t;>I}%&jI|u5Mm4yD3cY6NhPW{YQm}a&|^v z=^rJuSFv4%zEV4TV9(pN&L%#1T2dfbNwmQP-*@8u5zYob4VV^fG?@IRozWkYD+|vL zwjG|}ryAR{iC8Ccw!zO@rbQc5mzK1>+Hj(?@N{M+(FPNI1=*fxqfg^}wvAoijTfp6 zD~UFk;FiJm1RJqTaC>E1AR-DkK5Dm;C-r6uEu57^ubANbj_nCHVwvEl0n-8zQT|?i z+pBb$#|h65RuYIu;orV7+WzT_O!MazEnbe+ zc-MX2jbE3aWG$o4-Ml8Tz^1C^B6Finq}er1AXrJ4J1jhG^m(s>|MT~I2n7H30Nb9s-T!zFKOA+N4?o+SL<0Nb{Hr(s-wGvUi^+b^A&Z*;v<#3m+>>L3uT zWR0FD4mA%){pL@c5~(mTo_=+KJ+!``tcb zlef-LIPYwOm2~;PmSC2-si`!+V9X zY1YLt*5-KG00A59VR)}FD$lw&-jvd=oV)gaEfEB4u!rHj!uJT)#qrj#HbB4zdl=p; ze2HOQ90$YN00A59VR)~0+Et%*ag161Ue^NzY_Nyny~5Ww*2OVq$9vfT0UPXLc&`pn z9MFXg%n|pp0RlGI!|+}mCL7R&4a}7GvH=1%*u(H%;Tg`lp<`!;v6l@Hu)!X_>{ZfB zH44NjJ0=`M>3=r7iL*q2{=bNeM1cOkh^s_^1|fHSv+~@1b+&OY6~2QItD1G6+iQ_a zZK+e0M^bnWHIAK`{r`W0=OH^Yu>b!AW-G&nGY>gL+?)S&*Dywur@%z274IZ zE6hV=T^u_rq}_3VfDQIAyjPeZz`8hI7d8$Mu)!XN_X^+pSr^Bv!rA}<8|-0tukiJr zb#aXSAiVA<5U{}>hW85J;#n8R31Q;^0UPXLc&`>x+l4M{EDUP{1Z=Q}US6>lh`7{e zyYq{DVy-~M+18n0C0$*tlH8&7QK@3L+JO4hH2&VJX7>s(M>IQqK5-(}gJ z_$@X2#J=Y3+{={)`9BP@4JKI0`p)@vU$yOfd{LK2Qdq_JzZ-_SS)u;Ba zBHq3=SljUWQ~U4zdJND4Qnv$b1XMypH&XYaGm`scYw2c%V52blFU?DXx)I#<~MfFsmPZ@%?#I@ zB`(X?O<`i;otw69{1MS!KpIx^sd3=sF5f@3JDRM;1SDURi9oQD)!M%qY2I#CyGqt0Nq&Ji7qoRRH|r|U0jXxjZiX6m z7-%xVo|r@jq*lvMC}WO%?R&T0=O$Zcf|UZ&`oaa(X>pggmHDH#TD1E;JC0Ak@2s^? zci;Bv@tV%sol?KsfB&jdDDqt=hMP~`uT-tm2bB#b){OhhCR*<4D$oIGfA)fA`=u9r z2a;-=ti=Q?S?%h1VYSDBCEMF4))0vF+HKp$)*rfPOb4XKGrKE+ZlioVYY$b~2FJm4 zK>F{|45QP%9ElHq8=$^-akd>{wGX>!b^lvv+n5^lskSY{xAt+q$6c(*5B45tzWU;{ z@9tkyl*c9GaLw~|69`rcNKp$5nf-6=s5<=DnkEz34$ii9mo)iIpaW8)o5R#Ti(C4p z+^=si!JaT3kV=gnsGfRJ)VKfX6oaiZ!Ab$?wT{Eh;6D+G4cBN&ufJC#!Ua)Uw#Mu1 zm|oOwp^cre*8cmdt*xxcFRm@5zIZ#%*J)o(m5J`NmfC0KU)xfk1JbwO29#QvUfp`E z_#l;andsi=JKNrvo-HjK>%IvXV}4lWdy;FA$^XKhuu?!8^7s0V%&afdIw1XZC4+JQ=uKbc_-K>0 zm|&%V^lA2u%CKxLeZv;?7KjoDAK6~bT-H)!Iv|}}kYC+jqs;cUh3l$pgX3U2AT3MI zscv2OdbKTTL-WC;)3)v_8Cq-UTA#L~UB04~_T}!q_TLlsHnk!z|80ypJ7Q+l7qOoO znTR&d*k{G`Zz#|KX-M;)>i&8k`4`;($!PudQCs(u0*wV-wil499k{5hoYP_Z&)d7H zOt1~ntNz`3nvGs5=f4@TUtujKSScVK=n-ue8#~MYbIvFJY`fw6`W9bkC${dlJ*;)F zkM@1_b9PJ_Ck?hD-=M#(zE`GK;>pI1)TC$Vy-Xh2Um#d1Agvs}+o&3SBJt#yUh3+7 z=WN|EtMR#RPwH-d}=ghpp%>7c4;CC{v zYW#K=p5WTYv`BE(=PDDP;QN3HRuXM+U1EZjY$Ek~aDKS@a5WHZaCVtsC6V9=xgQ-q zyG(GOnrXpCEJw=(D~SZxZ6;VrAR?IH-aXUd3CG2~{p;{o zkVhl94)T*hw82jUCRiyv!4;Wlk>IBf6Kq=~`02w0D+xp_w^1f|Y)K#@nBd+E(*hC8 zt(pl|5(#cUOt6wbL~#3If|YC{^~u2ZITNh(5<#N_Ot6yZ6^|$Ib!J*5_$+>&bKMP3 zusx>36RaD)wR5%PH+rsJq7Bw%f|W#q{bhoc1R|F2XC_!lB=}vH304w_2qw5XGaa7b z8qRchf_q_1iv&N_m|$;3f}e>@u#!mdvz7@~3Qus4m1%*9<+IoxKl245f(bs$)dNWF zE%Co_&w_h3q75eatnhs}{ud^=-zC~$g3k*7+~sFGpB47G8^Jc1V5RV0aqm@pkFrvm znqB@ECR`OK*6F=+-Cy(pv(p%d6^SRBk-{T3PpPfAxcNIjy(fDcYz{ zb7)MklC^JC)2imDcW)T4u1l*X_pW6VSt42p1S{$C$a0mBnX4JWcCES!#KYqiwUFkmp4ZYu5a6T;H#%G!M1g|#fDGSsN7Y9mu_4)n0UQSQ`^R=i|qtj zm*4$jkUG3@lydTZd4-AIv9)dDM8YJEXNKtia;a|x5h=ST-`913bfTq z#;j5PyVJ=3B({&bcIid?nlJ6ULSuq$ThFUU$=e6_D`3Q)93c=_J5RE&dF0Tg09U%6lELS`j{4NaD8Qhl|&m%aC>E1 zw88b2304wqFv0DWX~9M;6Wsck7KjKYxb-nD5?qCuU?qWwWrABD(*hB}1h*xoMS|N8 z6Rf1m`C1NCUfthYeeug^gVzc2Dk@$*WbKhs=eTn8)i=$K-&8bt^&%6jq|0@BZVlF% z_@x^EP~YqPXOIbAcg3_W zZ##0u8x^C0%|?9bhc$-d`=-vA#g? zN-d^!`QNxK#-3j^^XpFvsl0-i3AU}v)vnd_-<&u^t<YZOb*~Ty$ zRx7LWN>3(O$=W-*Ohe`0X2aB+#fJKs;FiI(F6SBFLEZM(V`E*?&p|$mX|a{`j?W!T zKQl&s@?B~F>I_#S6P{htdR>{P@r?L~g?@<~A8}c$`o$cpC$s*$9BPA{0j1pT>MF00 z_ zl6L&ONU)MFcWd@oDbu^I`PvWrl`}1NN7i3{K|3>Vj$nh8bopB8^Tz48>gx0=PnDdr z_C|KNd`{bSRkXoMx;$ghMWySQb;_b)EmYnok>g+`YyIHl0;*OepYp+%H3XtW$)w0{ zZk^Sh^%6a_-ak%GNUXhVxLUJ(HG_9nWP)v5yA-UeW4?$;t5zAkN#Wg?nP4Sde%!Bu zSvP$N^=6_j>@~>*E9vq#xtj&oHEovIbV-cLyHj%HOzZOZ&2AglGBq@7rQNUazLiX{ zlC`VK@M31ue@&xsk>V=1L?&1%^|>2b*ATmHWt`f*`Vj4Ox~AII*L&H=pa0!JTk_8e z`}l08(3}bD%+Sg9N{DPrd)WW-YUaiQ!Adp}n?l4gv9M+Zo7QJE7ifWq$SU=;&l)_o zxyA%5iC!`Bd%meQ{a^Q10v(>HV5cdbaXA^}QHq)36NQtF8m_O#3VF9`0)k@7D`afEG zN#Xo3!Ab#1z12{CEkjN<&4%w1nc!%dwusjC%+)Q|`=&|x)K0(mRO1G=)3O(7qFufD zjeVS^PIFCcC4I`&RgFWJgUfQ}HJRY6$+R_dVAXEr{p+sFdD*hIn`SI9lOt1~6g_anb^Sz|V&|O>P^(ETwA^+YUh#3FJ(1v?Fu}G1QoHyy$_ESou=s@Vs z`h=IVyYlH6<+Jk{)n%C)2}JF(KifpTC5ttt1Jd*%txR988^&M3xdune1ltZsi_#4< zA0AxeUz4Z4!bICzM{FD22Q1Z?4oI!9M5vE^w+v;PPi2BVVLBjXs^Jf&e>1(BysM4M z`C)>UtkzyCTK)6aul*n8NpEm=nP8=WG@q1h)9dXAPqJuPH(O;!Adrf`ea~Y zXHn39E@>gqf{h6FmkCx1PjG&i4oFhvkJK~Y)sViz6MONQb6i>H?KLV`5ga9?DioKYs&c0gK~tB0AZX^e58bb`VWGQmm#DSO3JM%J%?Q`)wur!v9OG98ct zxvwdkD%>z0?yjwJgiNqfKx#W?r1`MRx!?ywzf5f1uBH~cYriz3G`?zLD|ucigL^WkQOAA>UMTsm(e;65+t}5=vBm@|1*GS1oKfCiv2%OBCf!XY c*b}A$QnSd8<|k82Cf1C7s<0LltQ3&`AE=QR%>V!Z diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index c19071779..1d0d9d706 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -334,7 +334,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 package="robot_localization", executable="ekf_node", name="ekf_global", - namespace=vehicle_config.namespace, + namespace=namespace_vehicle, parameters=[ekf_global_params.file], remappings=[ ("odometry/filtered", f"/{namespace_vehicle}/odometry/global"), diff --git a/predefined_configurations.py b/predefined_configurations.py index 37f6d76ef..5cec1ed14 100644 --- a/predefined_configurations.py +++ b/predefined_configurations.py @@ -248,7 +248,7 @@ def config_panther_gps_navigation(self) -> None: # noqa: D102 ip_address="10.15.20.5", ) gps = GPS("gps", position=(-0.08, -0.25, 0.2), orientation=(0, 0, -90)) - imu = IMU("xsens", position=(-0.23, -0.08, 0.18), orientation=(0, 0, -90)) + imu = IMU("xsens", position=(-0.23, -0.08, 0.18), orientation=(0, 0, 180)) camera = Camera("realsense", (0.18, 0, 0.2)) link(vehicle, lidar) From 0aef43423f98d219e5b2331675bf563f3ba09518 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Fri, 24 Apr 2026 15:51:08 +0200 Subject: [PATCH 102/119] integration with gazebo halfway there Signed-off-by: Peter Geurts --- .../xsens/urdf/xsens.urdf.xacro | 117 +++++++++++++----- .../alliander_gazebo/launch/gazebo.launch.py | 4 + .../src/alliander_nav2/launch/nav2.launch.py | 9 +- .../alliander_xsens/launch/xsens.launch.py | 4 +- 4 files changed, 92 insertions(+), 42 deletions(-) diff --git a/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro b/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro index 0a3ae3a0e..a24e23415 100644 --- a/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro +++ b/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro @@ -3,45 +3,94 @@ SPDX-FileCopyrightText: Alliander N. V. SPDX-License-Identifier: Apache-2.0 --> - + - - + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + true + 100 + ${namespace}/imu/data + false + false + imu_link + + + ENU + + + + 0.01.75e-49.7e-60.0 + 0.01.75e-49.7e-60.0 + 0.01.75e-49.7e-60.0 + + + + 0.03.0e-39.8e-50.0 + 0.03.0e-39.8e-50.0 + 0.03.0e-39.8e-50.0 + + + + + - + - - - - - + + + + diff --git a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py index d90d7bf45..07b0cfbed 100644 --- a/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py +++ b/alliander_gazebo/src/alliander_gazebo/launch/gazebo.launch.py @@ -83,6 +83,10 @@ def get_bridge_topics(platforms: list[T]) -> list[str]: bridge_topics.append( f"/{platform.namespace}/gps/fix@sensor_msgs/msg/NavSatFix@gz.msgs.NavSat" ) + if platform.platform_type == "IMU": + bridge_topics.append( + f"/{platform.namespace}/imu/data@sensor_msgs/msg/Imu@gz.msgs.IMU" + ) return bridge_topics diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 1d0d9d706..7a9bcda3b 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -98,11 +98,6 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 root_key=namespace_vehicle, ) - topic_imu = ( - f"/{namespace_vehicle}/imu/data" - if vehicle_config.simulation - else f"/{namespace_imu}/imu/data" - ) ekf_global_params = AdaptedYaml( get_file_path("alliander_nav2", ["config", "nav2"], "ekf_global.yaml"), { @@ -110,7 +105,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 "base_link_frame": f"{namespace_vehicle}/base_footprint", "odom0": f"/{namespace_vehicle}/odometry/wheels", "odom1": f"/{namespace_gps}/odometry/gps", - "imu0": topic_imu, + "imu0": f"/{namespace_imu}/imu/data", }, root_key=namespace_vehicle, ) @@ -349,7 +344,7 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 parameters=[navsat_transform_params.file], remappings=[ ("odometry/filtered", f"/{namespace_vehicle}/odometry/global"), - ("imu", topic_imu), + ("imu", f"/{namespace_imu}/imu/data"), ], ) diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index b9701f809..568d9db3a 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -9,7 +9,7 @@ from alliander_utilities.ros_utils import get_file_path from launch import LaunchContext, LaunchDescription from launch.actions import OpaqueFunction -from launch_ros.actions import Node +from launch_ros.actions import Node, SetParameter platform_arg = LaunchArgument("platform_config", "") @@ -30,6 +30,7 @@ def launch_setup(context: LaunchContext) -> list: platform="xsens", xacro="xsens.urdf.xacro", xacro_arguments={ + "use_sim": str(imu_config.simulation), "parent": "" if imu_config.parent.link else "world", }, ) @@ -76,6 +77,7 @@ def launch_setup(context: LaunchContext) -> list: ) return [ + SetParameter(name="use_sim_time", value=imu_config.simulation), Register.on_start(state_publisher, context), Register.on_start(static_tf, context), Register.on_start(imu_bridge_node, context) From ed6586a73785e646ca4e4cb5807af79c89e2bde1 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Tue, 28 Apr 2026 10:54:39 +0200 Subject: [PATCH 103/119] frame issue solved Signed-off-by: Peter Geurts --- .../xsens/urdf/xsens.urdf.xacro | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro b/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro index a24e23415..7afe8b399 100644 --- a/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro +++ b/alliander_core/src/alliander_description/xsens/urdf/xsens.urdf.xacro @@ -46,6 +46,16 @@ SPDX-License-Identifier: Apache-2.0 + + + + + + + + + + @@ -61,7 +71,7 @@ SPDX-License-Identifier: Apache-2.0 ${namespace}/imu/data false false - imu_link + ${namespace}/imu_link ENU From 8c343b55bc253d14b93d81094c343308036898bb Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Wed, 29 Apr 2026 12:10:02 +0200 Subject: [PATCH 104/119] auto restart on failure for Xsens due to IMU device not being detected by Xsens node once on every boot Signed-off-by: Peter Geurts --- alliander_xsens/docker-compose.yml | 2 ++ alliander_xsens/src/alliander_xsens/launch/xsens.launch.py | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alliander_xsens/docker-compose.yml b/alliander_xsens/docker-compose.yml index ace9f5379..33db70db8 100644 --- a/alliander_xsens/docker-compose.yml +++ b/alliander_xsens/docker-compose.yml @@ -6,6 +6,8 @@ services: image: allianderrobotics/xsens container_name: alliander_xsens runtime: nvidia + # USB device is not found by Xsens node first time after boot + restart: on-failure:3 network_mode: host privileged: true mem_limit: 6gb diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index 568d9db3a..ef4de657d 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -48,8 +48,6 @@ def launch_setup(context: LaunchContext) -> list: package="xsens_mti_ros2_driver", executable="xsens_mti_node", parameters=[parameter_file], - respawn=True, - respawn_delay=2.0, remappings=[ ("/imu/acceleration", "imu/acceleration"), ("/imu/angular_velocity", "imu/angular_velocity"), From 7a71c1fd8e87c296bea8c59b6df0e845bac9354b Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Wed, 29 Apr 2026 13:27:59 +0200 Subject: [PATCH 105/119] linting fix Signed-off-by: Peter Geurts --- .../src/alliander_gps/launch/gps.launch.py | 3 +-- .../src/alliander_nav2/launch/nav2.launch.py | 2 +- .../alliander_visualization/tool_manager.py | 4 ++-- .../scripts/estimate_magnetometer_bias.py | 19 +++++++++++++------ 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/alliander_gps/src/alliander_gps/launch/gps.launch.py b/alliander_gps/src/alliander_gps/launch/gps.launch.py index 149ae1294..495a2306e 100644 --- a/alliander_gps/src/alliander_gps/launch/gps.launch.py +++ b/alliander_gps/src/alliander_gps/launch/gps.launch.py @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: Alliander N. V. # # SPDX-License-Identifier: Apache-2.0 -from alliander_utilities.adapted_yaml import AdaptedYaml from alliander_utilities.config_objects import GPS from alliander_utilities.launch_argument import LaunchArgument from alliander_utilities.launch_utils import SKIP, state_publisher_node, static_tf_node @@ -9,7 +8,7 @@ from alliander_utilities.ros_utils import get_file_path from launch import LaunchContext, LaunchDescription from launch.actions import OpaqueFunction -from launch_ros.actions import Node, SetParameter +from launch_ros.actions import SetParameter T, F = True, False diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index 7a9bcda3b..b73767c3a 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -15,7 +15,7 @@ platform_arg = LaunchArgument("platform_config", "") -def launch_setup(context: LaunchContext) -> list: # noqa: PLR0915 +def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 """The launch setup. Args: diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py index 5fc9346ab..947ff2411 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py @@ -186,9 +186,9 @@ def add_gps(platform: GPS) -> None: @staticmethod def add_imu(_platform: IMU) -> None: - """Add IMU configurtions to Rviz and Vizanti. + """Add IMU configurtions to Rviz and Vizanti. Currently not implemented. Args: - platform (IMU): The IMU platform configuration, + _platform (IMU): The IMU platform configuration. """ pass diff --git a/alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py b/alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py index e388ec837..5765c0bdb 100755 --- a/alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py +++ b/alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py @@ -43,13 +43,16 @@ class MagneticBiasEstimator(Node): midpoint bias and optionally renders a 3-D scatter plot of the collected measurements. - Args: - csv_file: Optional path to a CSV file with pre-recorded measurements. - When provided the node estimates the bias immediately from the file - rather than waiting for live messages. """ def __init__(self, csv_file: str = "") -> None: + """Initialize the MagneticBiasEstimator. + + Args: + csv_file (str): Optional path to a CSV file with pre-recorded measurements. + When provided the node estimates the bias immediately from the file + rather than waiting for live messages. + """ super().__init__("magnetic_bias_estimator") self.sub_mag = self.create_subscription( MagneticField, MAG_TOPIC, self.mag_callback, 1 @@ -130,10 +133,14 @@ def parse_csv(self, csv_file: str) -> None: Args: csv_file: Path to the comma-separated data file. """ - import numpy as np + import numpy as np # noqa: PLC0415 data = np.loadtxt(csv_file, delimiter=",", skiprows=1) - self.mag_x, self.mag_y, self.mag_z = data[:, 0], data[:, 1], data[:, 2] + self.mag_x, self.mag_y, self.mag_z = ( + data[:, 0].tolist(), + data[:, 1].tolist(), + data[:, 2].tolist(), + ) self.num_mag_received = len(self.mag_x) self.estimate_bias() From 5a408f88d177651fffd5db569fa9713d701ff84c Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 30 Apr 2026 09:12:48 +0200 Subject: [PATCH 106/119] linting Signed-off-by: Peter Geurts --- .../test/test_gps_waypoint_follower.py | 22 ++- .../alliander_xsens/launch/xsens.launch.py | 1 + predefined_configurations.py | 1 - pyproject.toml | 1 + run_cppcheck.sh | 2 +- uv.lock | 126 +++++++++++++++++- 6 files changed, 144 insertions(+), 9 deletions(-) diff --git a/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py index 0b2ead4e2..f7332cb3e 100755 --- a/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py +++ b/alliander_nav2/src/alliander_nav2/test/test_gps_waypoint_follower.py @@ -34,21 +34,29 @@ class Route(Enum): """Enum containing predefined routes for GPS Waypoint Follower. Attributes: - KB_TRUCK_PARKING_SPACE: Route nearby Roboticalab entrance. + IPKW_KB_TRUCK_PARKING_SPACE: Route nearby Roboticalab entrance. + IPKW_KB_RAMP: Route nearby Roboticalab entrance. SIM_TIGHT_ALLEYS: Simulation route in Arnhem, with buildings close together. """ - KB_TRUCK_PARKING_SPACE = auto() + IPKW_KB_TRUCK_PARKING_SPACE = auto() + IPKW_KB_RAMP = auto() SIM_TIGHT_ALLEYS = auto() ROUTES: dict[Route, list[GPSWaypoint]] = { - Route.KB_TRUCK_PARKING_SPACE: [ + Route.IPKW_KB_TRUCK_PARKING_SPACE: [ GPSWaypoint(51.966663, 5.940867), GPSWaypoint(51.966511, 5.940912), GPSWaypoint(51.966512, 5.940945), GPSWaypoint(51.966661, 5.940892, yaw=math.pi / 4), ], + Route.IPKW_KB_RAMP: [ + GPSWaypoint(51.966705, 5.940815, yaw=-math.pi / 4), + GPSWaypoint(51.966705, 5.940869), + GPSWaypoint(51.966694, 5.940869), + GPSWaypoint(51.966694, 5.940815, yaw=math.pi / 4), + ], Route.SIM_TIGHT_ALLEYS: [ GPSWaypoint(51.977291, 5.954022, yaw=-math.pi), GPSWaypoint(51.977251, 5.954025, yaw=-math.pi / 4), @@ -166,9 +174,11 @@ def _yaw_to_quaternion(yaw: float) -> Quaternion: \n\nInput: " ) match route_input: - case "KB_TRUCK_PARKING_SPACE" | "0": - route = Route.KB_TRUCK_PARKING_SPACE - case "SIM_TIGHT_ALLEYS" | "1": + case "IKPW_KB_TRUCK_PARKING_SPACE" | "0": + route = Route.IPKW_KB_TRUCK_PARKING_SPACE + case "IKPW_KB_RAMP" | "1": + route = Route.IPKW_KB_RAMP + case "SIM_TIGHT_ALLEYS" | "2": route = Route.SIM_TIGHT_ALLEYS case _: print("No valid route given. Defaulting to SIM_TIGHT_ALLEYS.") diff --git a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py index ef4de657d..4c789a498 100644 --- a/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py +++ b/alliander_xsens/src/alliander_xsens/launch/xsens.launch.py @@ -48,6 +48,7 @@ def launch_setup(context: LaunchContext) -> list: package="xsens_mti_ros2_driver", executable="xsens_mti_node", parameters=[parameter_file], + respawn=True, remappings=[ ("/imu/acceleration", "imu/acceleration"), ("/imu/angular_velocity", "imu/angular_velocity"), diff --git a/predefined_configurations.py b/predefined_configurations.py index 5cec1ed14..fef6fca3a 100644 --- a/predefined_configurations.py +++ b/predefined_configurations.py @@ -239,7 +239,6 @@ def config_panther_gps_navigation(self) -> None: # noqa: D102 vehicle.nav2_config.controller = "mppi" vehicle.nav2_config.navigation = True vehicle.nav2_config.gps = True - vehicle.nav2_config.imu_topic = "/xsens/imu/data" vehicle.nav2_config.window_size = 50 lidar = Lidar( "velodyne", diff --git a/pyproject.toml b/pyproject.toml index 680dfea9e..f8f07109b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ alliander-visualization = [ "tornado>=6.5.4", ] alliander-xsens = [ + "matplotlib>=3.10.9", "pyserial>=3.5", ] ros = [ diff --git a/run_cppcheck.sh b/run_cppcheck.sh index fdf24cd90..628a6002a 100755 --- a/run_cppcheck.sh +++ b/run_cppcheck.sh @@ -7,5 +7,5 @@ cppcheck \ --suppressions-list=.cppcheck-suppressions \ --enable=warning,performance,portability,style \ --error-exitcode=1 \ - $(find . -name *.cpp) + $(find . -name *.cpp | grep -v '.venv\|.nvim') diff --git a/uv.lock b/uv.lock index a33bf3291..baa562fd0 100644 --- a/uv.lock +++ b/uv.lock @@ -115,6 +115,7 @@ alliander-visualization = [ { name = "tornado" }, ] alliander-xsens = [ + { name = "matplotlib" }, { name = "pyserial" }, ] documentation = [ @@ -172,7 +173,10 @@ alliander-visualization = [ { name = "simplejpeg", specifier = ">=1.9.0" }, { name = "tornado", specifier = ">=6.5.4" }, ] -alliander-xsens = [{ name = "pyserial", specifier = ">=3.5" }] +alliander-xsens = [ + { name = "matplotlib", specifier = ">=3.10.9" }, + { name = "pyserial", specifier = ">=3.5" }, +] documentation = [ { name = "myst-parser", specifier = ">=5.0.0" }, { name = "sphinx", specifier = ">=9.1.0" }, @@ -337,6 +341,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -413,6 +448,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/6a/cdee9ff7f2b7c6ddc219fd95b7c70c0a3d9f0367a506e9793eedfc72e337/flake8_pyproject-1.2.4-py3-none-any.whl", hash = "sha256:ea34c057f9a9329c76d98723bb2bb498cc6ba8ff9872c4d19932d48c91249a77", size = 5694, upload-time = "2025-11-28T21:40:01.309Z" }, ] +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -556,6 +608,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, +] + [[package]] name = "lark" version = "1.3.1" @@ -655,6 +734,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/2d/edc147aa1dce9e0e377687d453e62fdfcbccc08cd35d0b7413e3485c4b92/mashumaro-3.17-py3-none-any.whl", hash = "sha256:3964e2c804f62de9e4c58fb985de71dcd716f9507cc18374b1bd5c4f1a1b879b", size = 94198, upload-time = "2025-10-03T21:09:25.436Z" }, ] +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -822,6 +927,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" From fedb046be0b145e7e21c548a89faa192cde6e110 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 30 Apr 2026 15:24:17 +0200 Subject: [PATCH 107/119] ignore imu in tool manager Signed-off-by: Peter Geurts --- .../alliander_visualization/tool_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py index 947ff2411..837acbd31 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py @@ -53,7 +53,7 @@ def __init__(self, config: VisualizationConfig, platform_list: PlatformList): case "GPS": self.add_gps(GPS.from_str(platform.to_str())) case "IMU": - self.add_imu(IMU.from_str(platform.to_str())) + pass case _: raise NotImplementedError( f"Configuration for platform {type(platform).__name__} is not implemented." From fcdda0ab60efe0aa5d262c274250c0157e72bf9e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:21:03 +0000 Subject: [PATCH 108/119] Bump lxml from 6.0.2 to 6.1.0 Bumps [lxml](https://github.com/lxml/lxml) from 6.0.2 to 6.1.0. - [Release notes](https://github.com/lxml/lxml/releases) - [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt) - [Commits](https://github.com/lxml/lxml/compare/lxml-6.0.2...lxml-6.1.0) --- updated-dependencies: - dependency-name: lxml dependency-version: 6.1.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Signed-off-by: Peter Geurts --- uv.lock | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/uv.lock b/uv.lock index baa562fd0..e8b71cc71 100644 --- a/uv.lock +++ b/uv.lock @@ -646,28 +646,28 @@ wheels = [ [[package]] name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, ] [[package]] From 96ce9e5929a256692e9100825f538aca20ca352a Mon Sep 17 00:00:00 2001 From: Jelmer de Wolde Date: Thu, 30 Apr 2026 08:47:33 +0000 Subject: [PATCH 109/119] Build and use branch tagged images when alliander_core is changed. Signed-off-by: Jelmer de Wolde Signed-off-by: Peter Geurts --- .github/workflows/pr.yml | 3 +++ image_manager.py | 6 +++++- start.py | 5 ++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1a68a0fe4..ecd8454e7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -169,6 +169,9 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ env.BRANCH_NAME }} - name: Set up python3 uses: actions/setup-python@v6 with: diff --git a/image_manager.py b/image_manager.py index d46c4603e..82b3be7ff 100755 --- a/image_manager.py +++ b/image_manager.py @@ -63,7 +63,11 @@ def colored_bool(value: bool) -> str: changed_packages = utils.get_changed_packages() for repository in self.selected: package = f"alliander_{repository}" - tag = utils.get_git_branch() if package in changed_packages else "latest" + tag = ( + "latest" + if set({package, "alliander_core"}).isdisjoint(changed_packages) + else utils.get_git_branch() + ) tag = "latest" if tag == "main" else tag if pull: self.run_pull_subprocess(repository, tag) diff --git a/start.py b/start.py index 9e4c2b8d3..ba4ca31d7 100755 --- a/start.py +++ b/start.py @@ -241,7 +241,10 @@ def load_service_base(self, package: str, command: str) -> dict: # Use the branch tag if the package has changes: name_branch = subprocess.getoutput("git rev-parse --abbrev-ref HEAD") - if package in self.changed_packages and name_branch != "main": + if ( + not set({package, "alliander_core"}).isdisjoint(self.changed_packages) + and name_branch != "main" + ): service["image"] += f":{name_branch}" if self.mode == "configuration-no-nvidia": From ac0fe47fba69d02cd9cbcd5024a65d5c23cd32d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:52:09 +0000 Subject: [PATCH 110/119] Bump python-multipart from 0.0.22 to 0.0.26 Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.22 to 0.0.26. - [Release notes](https://github.com/Kludex/python-multipart/releases) - [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md) - [Commits](https://github.com/Kludex/python-multipart/compare/0.0.22...0.0.26) --- updated-dependencies: - dependency-name: python-multipart dependency-version: 0.0.26 dependency-type: indirect ... Signed-off-by: dependabot[bot] Signed-off-by: Peter Geurts --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index e8b71cc71..83f14f8d5 100644 --- a/uv.lock +++ b/uv.lock @@ -1189,11 +1189,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, ] [[package]] From 8d893322bbd356818d31a4f935c5818ffca55e09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:52:19 +0000 Subject: [PATCH 111/119] Bump pytest from 9.0.2 to 9.0.3 Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Signed-off-by: Peter Geurts --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f8f07109b..9036bfb06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ ros = [ "lark>=1.3.1", ] testing = [ - "pytest>=9.0.2", + "pytest>=9.0.3", "pytest-timeout>=2.4.0", ] diff --git a/uv.lock b/uv.lock index 83f14f8d5..a00d48dda 100644 --- a/uv.lock +++ b/uv.lock @@ -199,7 +199,7 @@ ros = [ { name = "lark", specifier = ">=1.3.1" }, ] testing = [ - { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, ] @@ -1128,7 +1128,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1137,9 +1137,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] From be8a5a22bc6f00f3198ea40a67cacc62f5cb0f8a Mon Sep 17 00:00:00 2001 From: Jelmer de Wolde Date: Thu, 30 Apr 2026 13:35:17 +0200 Subject: [PATCH 112/119] 435 integrate meta (#437) * Add Meta platform to our system. Signed-off-by: Jelmer de Wolde * First teleoperation. Signed-off-by: Jelmer de Wolde * Rewrite teleoperation python node to CPP meta manager node. Signed-off-by: Jelmer de Wolde * Use meta as flag, not as platform. Signed-off-by: Jelmer de Wolde * Add support for GUI debugging using dev container. Signed-off-by: Jelmer de Wolde * Tune servo params. Signed-off-by: Jelmer de Wolde * Switch to meta_quest_reader package. Signed-off-by: Jelmer de Wolde * Switch to ppadb-reborn that includes fixes for escape sequences. Signed-off-by: Jelmer de Wolde * Add scrcpy to stream quest. Signed-off-by: Jelmer de Wolde * Start other containers only when meta launches correctly. Signed-off-by: Jelmer de Wolde * Fix linting. Signed-off-by: Jelmer de Wolde * Fix configuration, to support Meta, Joystick and GUI. Signed-off-by: Jelmer de Wolde * Set servo command type from joystick manager and meta manager. Signed-off-by: Jelmer de Wolde * Add documentation. Signed-off-by: Jelmer de Wolde * Undo scale changes in servo params. Signed-off-by: Jelmer de Wolde * Apply feedback. Signed-off-by: Jelmer de Wolde --------- Signed-off-by: Jelmer de Wolde Signed-off-by: Peter Geurts --- .devcontainer/dev/Dockerfile | 6 + .devcontainer/dev/devcontainer.json | 15 +- .../franka/urdf/franka.urdf.xacro | 2 +- .../alliander_joystick.Dockerfile | 3 + .../src/alliander_joystick/CMakeLists.txt | 4 +- .../include/joystick_manager.hpp | 30 +- .../launch/joystick.launch.py | 26 +- .../src/joystick_manager.cpp | 70 +- alliander_meta/alliander_meta.Dockerfile | 40 + alliander_meta/docker-compose.yml | 17 + .../src/alliander_meta/CMakeLists.txt | 62 ++ .../alliander_meta/alliander_meta/__init__.py | 3 + .../alliander_meta/meta_quest_reader.py | 725 ++++++++++++++ .../alliander_meta/include/meta_manager.hpp | 79 ++ .../src/alliander_meta/launch/meta.launch.py | 78 ++ alliander_meta/src/alliander_meta/package.xml | 34 + .../src/alliander_meta/src/meta_manager.cpp | 206 ++++ .../alliander_meta/src_py/meta_quest_node.py | 85 ++ .../config/fr3.srdf | 16 +- .../config/kinematics.yaml | 2 +- .../config/servo_params.yaml | 2 +- .../include/moveit_manager.hpp | 5 +- .../alliander_moveit/launch/moveit.launch.py | 14 +- .../alliander_moveit/src/moveit_manager.cpp | 38 +- .../src_py/alliander_gui.py | 2 - components.yml | 4 + conftest.py | 11 +- docs/content/input_devices.md | 83 ++ docs/index.rst | 1 + docs/vid/input_devices/quest_control.mp4 | 3 + .../input_devices/quest_control.mp4.license | 3 + pyproject.toml | 5 + start.py | 26 + uv.lock | 914 +++++++++++++----- 34 files changed, 2236 insertions(+), 378 deletions(-) create mode 100644 alliander_meta/alliander_meta.Dockerfile create mode 100644 alliander_meta/docker-compose.yml create mode 100644 alliander_meta/src/alliander_meta/CMakeLists.txt create mode 100644 alliander_meta/src/alliander_meta/alliander_meta/__init__.py create mode 100644 alliander_meta/src/alliander_meta/alliander_meta/meta_quest_reader.py create mode 100644 alliander_meta/src/alliander_meta/include/meta_manager.hpp create mode 100644 alliander_meta/src/alliander_meta/launch/meta.launch.py create mode 100644 alliander_meta/src/alliander_meta/package.xml create mode 100644 alliander_meta/src/alliander_meta/src/meta_manager.cpp create mode 100755 alliander_meta/src/alliander_meta/src_py/meta_quest_node.py create mode 100644 docs/content/input_devices.md create mode 100644 docs/vid/input_devices/quest_control.mp4 create mode 100644 docs/vid/input_devices/quest_control.mp4.license diff --git a/.devcontainer/dev/Dockerfile b/.devcontainer/dev/Dockerfile index 533ee4aa9..ea6fcc382 100644 --- a/.devcontainer/dev/Dockerfile +++ b/.devcontainer/dev/Dockerfile @@ -43,6 +43,11 @@ RUN git clone --depth=1 --filter=blob:none -b v3.1.1 \ && cd src/franka_ros2 \ && git sparse-checkout set franka_msgs +# Install debug tools: +RUN apt update && apt install -y --no-install-recommends \ + ros-$ROS_DISTRO-plotjuggler-ros \ + ros-$ROS_DISTRO-rqt-tf-tree + # Setup configuration files: COPY .devcontainer/dev/.dev_bashrc /.dev_bashrc RUN echo "source /.dev_bashrc" >> /home/$REMOTE_USER/.bashrc @@ -52,6 +57,7 @@ COPY alliander_core/src/ /alliander/ros/src COPY alliander_diagnostics/src/ /alliander/ros/src COPY alliander_franka/src/ /alliander/ros/src COPY alliander_joystick/src/ /alliander/ros/src +COPY alliander_meta/src/ /alliander/ros/src COPY alliander_moveit/src/ /alliander/ros/src COPY alliander_nav2/src/ /alliander/ros/src COPY alliander_xsens/src/ /alliander/ros/src diff --git a/.devcontainer/dev/devcontainer.json b/.devcontainer/dev/devcontainer.json index 9a01184cd..b86b7be12 100644 --- a/.devcontainer/dev/devcontainer.json +++ b/.devcontainer/dev/devcontainer.json @@ -14,8 +14,21 @@ "workspaceMount": "source=${localWorkspaceFolder},target=/alliander_robotics,type=bind", "workspaceFolder": "/alliander_robotics", "overrideCommand": true, + "mounts": [ + "source=/tmp/.X11-unix,target=/tmp/.X11-unix,type=bind,consistency=cached" + ], "runArgs": [ - "--rm" + "--rm", + "--privileged", + "--network=host", + "-e", + "DISPLAY", + "-e", + "NVIDIA_VISIBLE_DEVICES=all", + "-e", + "NVIDIA_DRIVER_CAPABILITIES=all", + "-e", + "RMW_IMPLEMENTATION=rmw_cyclonedds_cpp" ], "customizations": { "vscode": { diff --git a/alliander_core/src/alliander_description/franka/urdf/franka.urdf.xacro b/alliander_core/src/alliander_description/franka/urdf/franka.urdf.xacro index 020d7b1cf..7ec16da9d 100644 --- a/alliander_core/src/alliander_description/franka/urdf/franka.urdf.xacro +++ b/alliander_core/src/alliander_description/franka/urdf/franka.urdf.xacro @@ -11,7 +11,7 @@ SPDX-License-Identifier: Apache-2.0 - + diff --git a/alliander_joystick/alliander_joystick.Dockerfile b/alliander_joystick/alliander_joystick.Dockerfile index 29391ee82..ddfd3d9b8 100644 --- a/alliander_joystick/alliander_joystick.Dockerfile +++ b/alliander_joystick/alliander_joystick.Dockerfile @@ -7,6 +7,9 @@ FROM $BASE_IMAGE ARG COLCON_BUILD_SEQUENTIAL ENV ROS_DISTRO=jazzy +# Install ROS depenencies: +RUN apt update && apt install -y ros-$ROS_DISTRO-moveit-msgs + # Install repo packages: WORKDIR /$WORKDIR/ros COPY alliander_core/src/ /$WORKDIR/ros/src diff --git a/alliander_joystick/src/alliander_joystick/CMakeLists.txt b/alliander_joystick/src/alliander_joystick/CMakeLists.txt index 6f03f6bc5..647de47d3 100644 --- a/alliander_joystick/src/alliander_joystick/CMakeLists.txt +++ b/alliander_joystick/src/alliander_joystick/CMakeLists.txt @@ -13,12 +13,13 @@ find_package(rclcpp REQUIRED) find_package(rclcpp_action REQUIRED) find_package(geometry_msgs REQUIRED) find_package(sensor_msgs REQUIRED) +find_package(moveit_msgs REQUIRED) find_package(std_srvs REQUIRED) find_package(alliander_interfaces REQUIRED) # C++ executables: include_directories(include) -add_executable(joystick_manager +add_executable(joystick_manager src/main.cpp src/joystick_manager.cpp ) @@ -30,6 +31,7 @@ ament_target_dependencies(joystick_manager rclcpp_action geometry_msgs sensor_msgs + moveit_msgs std_srvs alliander_interfaces ) diff --git a/alliander_joystick/src/alliander_joystick/include/joystick_manager.hpp b/alliander_joystick/src/alliander_joystick/include/joystick_manager.hpp index 8d8b450f3..2131efc8f 100644 --- a/alliander_joystick/src/alliander_joystick/include/joystick_manager.hpp +++ b/alliander_joystick/src/alliander_joystick/include/joystick_manager.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,7 @@ using std::placeholders::_1; using std::placeholders::_2; typedef alliander_interfaces::action::TriggerAction TriggerAction; +typedef moveit_msgs::srv::ServoCommandType ServoCommandType; enum Button { A = 0, // Switch between platform modes @@ -62,28 +64,19 @@ class JoystickManager { /// Client to move the arm back to home position rclcpp::Client::SharedPtr srv_client_arm_home; + /// Client to change the command type of the servo node + rclcpp::Client::SharedPtr + srv_client_switch_servo_command_type; /// Client to trigger the arm's gripper to open rclcpp_action::Client::SharedPtr action_client_gripper_open; /// Client to trigger the arm's gripper to close rclcpp_action::Client::SharedPtr action_client_gripper_close; // ROS2 get parameter variables: - /// The arm twist topic - std::string arm_topic; - /// Frame ID of the arm - std::string arm_frame_id; - /// Name of the arm's gripper - std::string arm_gripper_name; - /// Service name with which the arm can be moved back to its home position - std::string arm_home_service; - /// Service name with which the arm's servo node can be paused - std::string arm_pause_servo_service; - /// The vehicle twist topic - std::string vehicle_topic; - /// Service name for resetting the vehicle's E-stop - std::string vehicle_estop_reset_service; - /// Service name for triggering the vehicle's E-stop - std::string vehicle_estop_trigger_service; + /// The namespace of the arm + std::string namespace_arm; + /// The namespace of the vehicle + std::string namespace_vehicle; // Other variables: /// Previous input values of the Joy message @@ -182,6 +175,11 @@ class JoystickManager { */ void move_arm_to_home(); + /** + * @brief Call the service to switch the command type of the servo node + */ + void switch_servo_command_type(); + /** * @brief (un)pause the servo node via a service request. * @param pause indication whether the servo node should be paused (true) or diff --git a/alliander_joystick/src/alliander_joystick/launch/joystick.launch.py b/alliander_joystick/src/alliander_joystick/launch/joystick.launch.py index 51bd25a55..f682f0c63 100644 --- a/alliander_joystick/src/alliander_joystick/launch/joystick.launch.py +++ b/alliander_joystick/src/alliander_joystick/launch/joystick.launch.py @@ -29,15 +29,15 @@ def launch_setup(context: LaunchContext) -> list: platforms = PlatformList.from_str(platform_list_arg.string_value(context)).platforms # Create default namespaces which can be ignored when left unchanged - arm_namespace = "/arm" - vehicle_namespace = "/vehicle" + arm_namespace = "arm" + vehicle_namespace = "vehicle" # Find arm / vehicle platforms in the platform list for platform in platforms: match platform.platform_type: case "Arm": - if arm_namespace == "/arm": - arm_namespace = f"/{platform.namespace}" + if arm_namespace == "arm": + arm_namespace = platform.namespace else: warnings.warn( "No support for multiple arms yet, only accepting the first arm.", @@ -46,8 +46,8 @@ def launch_setup(context: LaunchContext) -> list: ) case "Vehicle": - if vehicle_namespace == "/vehicle": - vehicle_namespace = f"/{platform.namespace}" + if vehicle_namespace == "vehicle": + vehicle_namespace = platform.namespace else: warnings.warn( "No support for multiple vehicles yet, only accepting the first vehicle.", @@ -58,7 +58,7 @@ def launch_setup(context: LaunchContext) -> list: case _: pass - if arm_namespace == "/arm" and vehicle_namespace == "/vehicle": + if arm_namespace == "arm" and vehicle_namespace == "vehicle": raise RuntimeError( "No arm/vehicle platform present, cancelling the joystick manager." ) @@ -68,16 +68,8 @@ def launch_setup(context: LaunchContext) -> list: executable="joystick_manager", name="joystick_manager", parameters=[ - {"arm_cmd_topic": f"{arm_namespace}/servo_node/delta_twist_cmds"}, - {"arm_frame_id": f"{arm_namespace[1:]}/fr3_link1"}, - {"arm_gripper_name": f"{arm_namespace}/gripper"}, - { - "arm_home_service": f"{arm_namespace}/moveit_manager/move_to_configuration" - }, - {"arm_pause_servo_service": f"{arm_namespace}/servo_node/pause_servo"}, - {"vehicle_cmd_topic": f"{vehicle_namespace}/cmd_vel_joy"}, - {"vehicle_estop_reset": f"{vehicle_namespace}/hardware/e_stop_reset"}, - {"vehicle_estop_trigger": f"{vehicle_namespace}/hardware/e_stop_trigger"}, + {"namespace_arm": arm_namespace}, + {"namespace_vehicle": vehicle_namespace}, ], ) diff --git a/alliander_joystick/src/alliander_joystick/src/joystick_manager.cpp b/alliander_joystick/src/alliander_joystick/src/joystick_manager.cpp index 9e488bcbc..87a51b9f4 100644 --- a/alliander_joystick/src/alliander_joystick/src/joystick_manager.cpp +++ b/alliander_joystick/src/alliander_joystick/src/joystick_manager.cpp @@ -5,17 +5,8 @@ #include "joystick_manager.hpp" JoystickManager::JoystickManager(rclcpp::Node::SharedPtr node) : node(node) { - arm_topic = node->get_parameter("arm_cmd_topic").as_string(); - arm_frame_id = node->get_parameter("arm_frame_id").as_string(); - arm_gripper_name = node->get_parameter("arm_gripper_name").as_string(); - arm_home_service = node->get_parameter("arm_home_service").as_string(); - arm_pause_servo_service = - node->get_parameter("arm_pause_servo_service").as_string(); - vehicle_topic = node->get_parameter("vehicle_cmd_topic").as_string(); - vehicle_estop_reset_service = - node->get_parameter("vehicle_estop_reset").as_string(); - vehicle_estop_trigger_service = - node->get_parameter("vehicle_estop_trigger").as_string(); + namespace_arm = node->get_parameter("namespace_arm").as_string(); + namespace_vehicle = node->get_parameter("namespace_vehicle").as_string(); initialize_joystick_manager(); @@ -36,27 +27,29 @@ void JoystickManager::initialize_joystick_manager() { std::bind(&JoystickManager::joy_cb, this, _1)); // Publishers - pub_arm_vel = - node->create_publisher(arm_topic, 10); + pub_arm_vel = node->create_publisher( + "/" + namespace_arm + "/servo_node/delta_twist_cmds", 10); pub_vehicle_vel = node->create_publisher( - vehicle_topic, 10); + "/" + namespace_vehicle + "/cmd_vel_joy", 10); // Service clients srv_client_estop_trigger = node->create_client( - vehicle_estop_trigger_service); - srv_client_estop_reset = - node->create_client(vehicle_estop_reset_service); - srv_client_pause_servo = - node->create_client(arm_pause_servo_service); + "/" + namespace_vehicle + "/hardware/e_stop_trigger"); + srv_client_estop_reset = node->create_client( + "/" + namespace_vehicle + "/hardware/e_stop_reset"); + srv_client_pause_servo = node->create_client( + "/" + namespace_arm + "/servo_node/pause_servo"); srv_client_arm_home = node->create_client( - arm_home_service); + "/" + namespace_arm + "/moveit_manager/move_to_configuration"); + srv_client_switch_servo_command_type = node->create_client( + "/" + namespace_arm + "/servo_node/switch_command_type"); // Action clients action_client_gripper_open = rclcpp_action::create_client( - node, arm_gripper_name + "/open"); + node, "/" + namespace_arm + "/open"); action_client_gripper_close = rclcpp_action::create_client( - node, arm_gripper_name + "/close"); + node, "/" + namespace_arm + "/close"); // Log initial mode switch (current_mode) { @@ -125,11 +118,13 @@ void JoystickManager::handle_button_input(const std::vector& buttons) { return; case vehicle_mode: RCLCPP_INFO(node->get_logger(), "Switch to ARM mode."); + switch_servo_command_type(); current_mode = arm_mode; pub_vehicle_vel->publish(geometry_msgs::msg::TwistStamped{}); return; case no_mode: RCLCPP_INFO(node->get_logger(), "Switch to ARM mode."); + switch_servo_command_type(); current_mode = arm_mode; return; default: @@ -222,7 +217,7 @@ void JoystickManager::handle_arm_movement(const float& x, const float& y, const float& rotation) { geometry_msgs::msg::TwistStamped twist; twist.header.stamp = node->now(); - twist.header.frame_id = arm_frame_id; + twist.header.frame_id = namespace_arm + "/" + "fr3_link1"; twist.twist.linear.x = (std::abs(x) > dead_axis_zone) ? (x * arm_speed_scale) @@ -283,6 +278,35 @@ void JoystickManager::move_arm_to_home() { }); } +// Switch the servo command type to TWIST: +void JoystickManager::switch_servo_command_type() { + auto request = std::make_shared(); + request->command_type = 1; + + if (!srv_client_switch_servo_command_type->service_is_ready()) { + RCLCPP_WARN(node->get_logger(), + "'Switch servo command type' service not available."); + } + + srv_client_switch_servo_command_type->async_send_request( + request, [this](rclcpp::Client::SharedFuture future) { + try { + auto response = future.get(); + + if (response->success) { + RCLCPP_INFO(node->get_logger(), "Servo command type is TWIST."); + } else { + RCLCPP_WARN(node->get_logger(), + "Failed to set servo command type."); + } + } catch (const std::exception& e) { + RCLCPP_ERROR(node->get_logger(), + "'Switch servo command type' service call failed: %s", + e.what()); + } + }); +} + void JoystickManager::pause_servo_node(bool pause) { auto request = std::make_shared(); request->data = pause; diff --git a/alliander_meta/alliander_meta.Dockerfile b/alliander_meta/alliander_meta.Dockerfile new file mode 100644 index 000000000..572fffbe6 --- /dev/null +++ b/alliander_meta/alliander_meta.Dockerfile @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +ARG BASE_IMAGE=ubuntu:latest +FROM $BASE_IMAGE + +ARG COLCON_BUILD_SEQUENTIAL +ENV ROS_DISTRO=jazzy + +# Install adb: +RUN apt update && apt install -y android-tools-adb + +# Install srcpy: +RUN apt update && apt install -y \ + ffmpeg libsdl2-2.0-0 adb wget gcc git pkg-config meson ninja-build libsdl2-dev libavcodec-dev \ + libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0 libusb-1.0-0-dev \ + && git clone https://github.com/Genymobile/scrcpy \ + && cd scrcpy \ + && ./install_release.sh + +# Install ROS depenencies: +RUN apt update && apt install -y ros-$ROS_DISTRO-moveit-msgs + +# Install repo packages: +WORKDIR /$WORKDIR/ros +COPY alliander_core/src/ /$WORKDIR/ros/src +COPY alliander_meta/src/ /$WORKDIR/ros/src +RUN /$WORKDIR/colcon_build.sh + +# Install python dependencies: +WORKDIR $WORKDIR +COPY pyproject.toml/ /$WORKDIR/pyproject.toml +RUN uv sync --group alliander-meta \ + && echo "export PYTHONPATH=\"$(dirname $(dirname $(uv python find)))/lib/python3.12/site-packages:\$PYTHONPATH\"" >> /root/.bashrc \ + && echo "export PATH=\"$(dirname $(dirname $(uv python find)))/bin:\$PATH\"" >> /root/.bashrc + +# Finalize +WORKDIR /$WORKDIR +ENTRYPOINT ["/entrypoint.sh"] +CMD ["sleep", "infinity"] diff --git a/alliander_meta/docker-compose.yml b/alliander_meta/docker-compose.yml new file mode 100644 index 000000000..e0bde2bf9 --- /dev/null +++ b/alliander_meta/docker-compose.yml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +services: + alliander_meta: + image: allianderrobotics/meta + container_name: alliander_meta + network_mode: host + privileged: true + mem_limit: 6gb + tty: true + volumes: + - "/tmp/.X11-unix:/tmp/.X11-unix" + - "/dev:/dev" + env_file: + - .env + command: ["/bin/bash", "-c", "ros2 launch alliander_meta meta.launch.py"] diff --git a/alliander_meta/src/alliander_meta/CMakeLists.txt b/alliander_meta/src/alliander_meta/CMakeLists.txt new file mode 100644 index 000000000..c82e55ffb --- /dev/null +++ b/alliander_meta/src/alliander_meta/CMakeLists.txt @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.5) +project(alliander_meta) + +# CMake dependencies: +find_package(ament_cmake REQUIRED) +find_package(ament_cmake_python REQUIRED) + +# Other dependencies: +find_package(rclcpp REQUIRED) +find_package(geometry_msgs REQUIRED) +find_package(sensor_msgs REQUIRED) +find_package(moveit_msgs REQUIRED) +find_package(tf2 REQUIRED) +find_package(tf2_msgs REQUIRED) +find_package(tf2_ros REQUIRED) +find_package(alliander_interfaces REQUIRED) + +# Python package: +ament_python_install_package(${PROJECT_NAME}) + +# Python executables: +install( + DIRECTORY src_py/ + DESTINATION lib/${PROJECT_NAME} +) + +# C++ executables: +include_directories(include) +add_executable(meta_manager src/meta_manager.cpp) +ament_target_dependencies(meta_manager + rclcpp + geometry_msgs + sensor_msgs + moveit_msgs + tf2 + tf2_msgs + tf2_ros + alliander_interfaces +) + +install( + TARGETS meta_manager + DESTINATION lib/${PROJECT_NAME} +) + +# Shared folders: +install( + DIRECTORY launch + DESTINATION share/${PROJECT_NAME} +) + +# Default test: +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + ament_lint_auto_find_test_dependencies() +endif() + +ament_package() diff --git a/alliander_meta/src/alliander_meta/alliander_meta/__init__.py b/alliander_meta/src/alliander_meta/alliander_meta/__init__.py new file mode 100644 index 000000000..cc29ca9e9 --- /dev/null +++ b/alliander_meta/src/alliander_meta/alliander_meta/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 diff --git a/alliander_meta/src/alliander_meta/alliander_meta/meta_quest_reader.py b/alliander_meta/src/alliander_meta/alliander_meta/meta_quest_reader.py new file mode 100644 index 000000000..729a89e36 --- /dev/null +++ b/alliander_meta/src/alliander_meta/alliander_meta/meta_quest_reader.py @@ -0,0 +1,725 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 + +# Based on: +# https://github.com/NeuracoreAI/meta_quest_teleop/blob/main/meta_quest_teleop/reader.py +# https://github.com/NeuracoreAI/meta_quest_teleop/blob/main/meta_quest_teleop/buttons_parser.py + +# ruff: noqa +# ty: ignore + +import os +import sys +import threading +from typing import Any, Callable, Literal + +import numpy as np +from ppadb.client import Client as AdbClient +from scipy.spatial.transform import Rotation + + +def parse_buttons(text: str) -> dict[str, Any]: + """Parse buttons from text. + + Args: + text: Text to parse. + + Returns: + Dictionary with button states. + """ # noqa: DOC1, DOC2, DOC5 + split_text = text.split(",") + buttons: dict[str, Any] = {} + if "R" in split_text: # right hand if available + split_text.remove("R") # remove marker + buttons.update( + { + "A": False, + "B": False, + "RThU": ( + False + ), # indicates that right thumb is up from the rest position + "RJ": False, # joystick pressed + "RG": False, # trigger on the grip + "RTr": False, # trigger on the index finger + } + ) + # besides following keys are provided: + # 'rightJS' / 'leftJS' - + # (x, y) position of joystick. x, y both in range (-1.0, 1.0) + # 'rightGrip' / 'leftGrip' - + # float value for trigger on the grip in range (0.0, 1.0) + # 'rightTrig' / 'leftTrig' - + # float value for trigger on the index finger in range (0.0, 1.0) + + if "L" in split_text: # left hand accordingly + split_text.remove("L") # remove marker + buttons.update( + { + "X": False, + "Y": False, + "LThU": False, + "LJ": False, + "LG": False, + "LTr": False, + } + ) + for key in buttons.keys(): + if key in list(split_text): + buttons[key] = True + split_text.remove(key) + for elem in split_text: + split_elem = elem.split(" ") + if len(split_elem) < 2: + continue + key = split_elem[0] + value = tuple([float(x) for x in split_elem[1:]]) + buttons[key] = value + return buttons + + +def eprint(*args: Any, **kwargs: Any) -> None: + """Print error messages to stderr.""" # noqa: DOC1, DOC2, DOC5 + RED = "\033[1;31m" + sys.stderr.write(RED) + print(*args, file=sys.stderr, **kwargs) + RESET = "\033[0;0m" + sys.stderr.write(RESET) + + +class MetaQuestReader: + """Meta Quest Reader class with high-level APIs for transforms and button callbacks. + + This class handles the Meta Quest device connection, data reading, and provides + clean APIs to access hand controller transforms in OpenXR and ROS coordinate + systems, button event callbacks, and analog input values. + """ + + def __init__( + self, + ip_address: str | None = None, + port: int = 5555, + APK_name: str = "com.rail.oculus.teleop", + run: bool = True, + axis_mask: list[int] | None = None, + ) -> None: + """Initialize the MetaQuestReader. + + Args: + ip_address: IP address to the device. If None, USB connection is used. + port: Port number for connection. Defaults to 5555. + APK_name: Android package name. Defaults to "com.rail.oculus.teleop". + run: Whether to start reader immediately. Defaults to True. + axis_mask: Mask for axes [x, y, z, roll, pitch, yaw]. 1 = enabled, 0 = disabled. + Masked axes (x, y, z, roll, pitch, yaw) will be zeroed. + """ # noqa: DOC1, DOC2, DOC5 + self.running = False + self.last_transforms: dict[str, Any] | None = {} + self.last_buttons: dict[str, Any] | None = {} + self._lock = threading.Lock() + self.tag = "wE9ryARX" + + self.ip_address = ip_address + self.port = port + self.APK_name = APK_name + + # Validate axis mask + if axis_mask is not None: + assert len(axis_mask) == 6, ( + "axis_mask must have 6 elements [x, y, z, roll, pitch, yaw]" + ) + assert np.all(np.isin(axis_mask, [0, 1])), "axis_mask values must be 0 or 1" + # NOTE: Because we are reading in openxr coordinates, we need to resort the mask for ROS coordinates + # x -> z, y -> -x, z -> -y , roll -> -pitch, pitch -> -roll, yaw -> yaw + self.axis_mask = np.array( + [ + axis_mask[1], + axis_mask[2], + axis_mask[0], + axis_mask[4], + axis_mask[5], + axis_mask[3], + ], + dtype=int, + ) + else: + self.axis_mask = None + + # Button state tracking for edge detection + self._prev_button_states: dict[str, bool] = {} + + # Callback system + # TODO: add more button event callbacks. + self._callbacks: dict[str, list[Callable]] = { + "button_b_pressed": [], + "button_a_pressed": [], + "button_x_pressed": [], + "button_y_pressed": [], + "button_rj_pressed": [], + "button_lj_pressed": [], + } + + self._callbacks_locks: dict[str, threading.Lock] = { + "button_b_pressed": threading.Lock(), + "button_a_pressed": threading.Lock(), + "button_x_pressed": threading.Lock(), + "button_y_pressed": threading.Lock(), + "button_rj_pressed": threading.Lock(), + "button_lj_pressed": threading.Lock(), + } + + # Cache latest transforms and button values (validated) + self._latest_transforms: dict[str, np.ndarray] = {} + self._latest_buttons: dict[str, Any] = {} + + self.device = self.get_device() + self.install(verbose=False) + if run: + self.run() + + def __del__(self) -> None: + """Destructor.""" + self.stop() + + def run(self) -> None: + """Start reading data from the Meta Quest device.""" + self.running = True + self.device.shell( + 'am start -n "com.rail.oculus.teleop/com.rail.oculus.teleop.MainActivity" ' + "-a android.intent.action.MAIN -c android.intent.category.LAUNCHER" + ) + self.thread = threading.Thread( + target=self.device.shell, args=("logcat -T 0", self.read_logcat_by_line) + ) + self.thread.start() + + def stop(self) -> None: + """Stop reading data from the Meta Quest device.""" + self.running = False + if hasattr(self, "thread"): + self.thread.join() + + def get_network_device(self, client: AdbClient, retry: int = 0) -> Any: + """Get the Meta Quest device over the network. + + Args: + client: ADB client. + retry: Retry count. + + Returns: + The Meta Quest device. + """ # noqa: DOC1, DOC2, DOC5 + try: + client.remote_connect(self.ip_address, self.port) + except RuntimeError as e: + eprint(f"⚠️ Failed to connect to device over network: {e}") + os.system("adb devices") + client.remote_connect(self.ip_address, self.port) + assert self.ip_address is not None + device = client.device(self.ip_address + ":" + str(self.port)) + + if device is None: + if retry == 1: + os.system("adb tcpip " + str(self.port)) + if retry == 2: + eprint( + "Make sure that device is running and is available at the " + "IP address specified as the OculusReader argument `ip_address`." + ) + eprint("Currently provided IP address:", self.ip_address) + eprint("Run `adb shell ip route` to verify the IP address.") + exit(1) + else: + self.get_device() + raise RuntimeError("Could not connect to device.") + return device + + def get_usb_device(self, client: AdbClient) -> Any: + """Get the Meta Quest device over USB. + + Args: + client: ADB client. + + Returns: + The Meta Quest device. + """ # noqa: DOC1, DOC2, DOC5 + try: + devices = client.devices() + except RuntimeError as e: + eprint(f"⚠️ Failed to get USB devices: {e}") + os.system("adb devices") + devices = client.devices() + for device in devices: + if device.serial.count(".") < 3: + return device + eprint( + "Device not found. Make sure that device is running " + "and is connected over USB" + ) + eprint("Run `adb devices` to verify that the device is visible.") + exit(1) + + def get_device(self) -> Any: + """Get the Meta Quest device. + + Returns: + The Meta Quest device. + """ # noqa: DOC1, DOC2, DOC5 + # Default is "127.0.0.1" and 5037 + client = AdbClient(host="127.0.0.1", port=5037) + if self.ip_address is not None: + return self.get_network_device(client) + else: + return self.get_usb_device(client) + + def install( + self, APK_path: str | None = None, verbose: bool = True, reinstall: bool = False + ) -> None: + """Install the APK on the Meta Quest device. + + Args: + APK_path: Path to the APK file. If None, the default path is used. + verbose: Whether to print messages. Defaults to True. + reinstall: Whether to reinstall the APK if it is already installed. + Defaults to False. + """ # noqa: DOC1, DOC2, DOC5 + try: + installed = self.device.is_installed(self.APK_name) + if not installed or reinstall: + if APK_path is None: + APK_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "APK", + "teleop-debug.apk", + ) + success = self.device.install(APK_path, test=True, reinstall=reinstall) + installed = self.device.is_installed(self.APK_name) + if installed and success: + print("APK installed successfully.") + else: + eprint("APK install failed.") + elif verbose: + print("APK is already installed.") + except RuntimeError: + eprint("Device is visible but could not be accessed.") + eprint( + "Run `adb devices` to verify that the device is visible and accessible." + ) + eprint( + 'If you see "no permissions" next to the device serial, ' + "please put on the Meta Quest and allow the access." + ) + exit(1) + + def uninstall(self, verbose: bool = True) -> None: + """Uninstall the APK from the Meta Quest device. + + Args: + verbose: Whether to print messages. Defaults to True. + """ # noqa: DOC1, DOC2, DOC5 + try: + installed = self.device.is_installed(self.APK_name) + if installed: + success = self.device.uninstall(self.APK_name) + installed = self.device.is_installed(self.APK_name) + if not installed and success: + print("APK uninstall finished.") + print( + "Please verify if the app disappeared from the " + 'list as described in "UNINSTALL.md".' + ) + print( + "For the resolution of this issue, please follow " + "https://github.com/Swind/pure-python-adb/issues/71." + ) + else: + eprint("APK uninstall failed") + elif verbose: + print("APK is not installed.") + except RuntimeError: + eprint("Device is visible but could not be accessed.") + eprint( + "Run `adb devices` to verify that the device is visible and accessible." + ) + eprint( + 'If you see "no permissions" next to the device serial, ' + "please put on the Oculus Quest and allow the access." + ) + exit(1) + + @staticmethod + def process_data( + string: str, + ) -> tuple[dict[str, np.ndarray] | None, dict[str, Any] | None]: + """Process data from the Meta Quest device. + + Args: + string: String to process. + + Returns: + Tuple of transformations and button states. + """ # noqa: DOC1, DOC2, DOC5 + try: + transforms_string, buttons_string = string.split("&") + except ValueError as e: + eprint(f"⚠️ Failed to split data string by '&': {e}") + return None, None + split_transform_strings = transforms_string.split("|") + transforms = {} + for pair_string in split_transform_strings: + transform = np.empty((4, 4)) + pair = pair_string.split(":") + if len(pair) != 2: + continue + left_right_char = pair[0] # is r or l + transform_string = pair[1] + values = transform_string.split(" ") + c = 0 + r = 0 + count = 0 + for value in values: + if not value: + continue + transform[r][c] = float(value) + c += 1 + if c >= 4: + c = 0 + r += 1 + count += 1 + if count == 16: + transforms[left_right_char] = transform + buttons = parse_buttons(buttons_string) + return transforms, buttons + + def extract_data(self, line: str) -> str: + """Extract data from a line of logcat output. + + Args: + line: Line of logcat output. + + Returns: + Extracted data. + """ # noqa: DOC1, DOC2, DOC5 + output = "" + if self.tag in line: + try: + output += line.split(self.tag + ": ")[1] + except ValueError as e: + eprint(f"⚠️ Failed to extract data from logcat line: {e}") + return output + + def get_transformations_and_buttons( + self, + ) -> tuple[dict[str, np.ndarray] | None, dict[str, Any] | None]: + """Get the latest transformations and button states. + + Returns: + Tuple of transformations and button states. + """ # noqa: DOC1, DOC2, DOC5 + with self._lock: + return self.last_transforms, self.last_buttons + + def _apply_axis_mask(self, transform: np.ndarray) -> np.ndarray: + """Apply axis mask to transform, zeroing masked axes. + + Args: + transform: Current 4x4 transformation matrix (OpenXR coordinates) + + Returns: + Masked 4x4 transformation matrix (OpenXR coordinates) + """ # noqa: DOC1, DOC2, DOC5 + # Start with current transform + + transform_translation = transform[:3, 3] + transform_translation_masked = transform_translation * self.axis_mask[:3] + transform_rotation = transform[:3, :3] + transform_rotation_euler = Rotation.from_matrix(transform_rotation).as_euler( + "xyz" + ) + transform_rotation_euler_masked = transform_rotation_euler * self.axis_mask[3:] + transform_rotation_masked = Rotation.from_euler( + "xyz", transform_rotation_euler_masked + ).as_matrix() + + transform_masked = np.eye(4) + transform_masked[:3, 3] = transform_translation_masked + transform_masked[:3, :3] = transform_rotation_masked + + return transform_masked + + def get_hand_controller_transform_openxr( + self, + hand: Literal["left", "right", "l", "r"] = "right", + ) -> np.ndarray | None: + """Get the 4x4 transformation matrix for a hand controller. + + The transform is in the OpenXR coordinate system. + See README.md "Coordinate Systems: ROS vs OpenXR" section for details. + + Args: + hand: Which hand ('left', 'right', 'l', or 'r') + + Returns: + 4x4 numpy array transformation matrix, or None if not + available + """ # noqa: DOC1, DOC2, DOC5 + hand_key = self._normalize_hand_key(hand) + + # Use hand key directly as the pointer transform key + key = hand_key + if key in self._latest_transforms: + with self._lock: + transform_openxr = self._latest_transforms[key].copy() + if self.axis_mask is not None: + transform_openxr = self._apply_axis_mask(transform_openxr) + return transform_openxr + return None + + def get_hand_controller_transform_ros( + self, + hand: Literal["left", "right", "l", "r"] = "right", + ) -> np.ndarray | None: + """Get the 4x4 transformation matrix for a hand controller. + + The transform is in the ROS coordinate system. This function applies + a quaternion [0.5, -0.5, -0.5, 0.5] to the + transform to convert from OpenXR coordinate system to ROS coordinate + system. + + See README.md "Coordinate Systems: ROS vs OpenXR" section for details + on the coordinate system differences and conversion. + + Args: + hand: Which hand ('left', 'right', 'l', or 'r') + + Returns: + 4x4 transformation matrix in ROS coordinates, or None if not + available + """ # noqa: DOC1, DOC2, DOC5 + transform_openxr = self.get_hand_controller_transform_openxr(hand) + + if transform_openxr is None: + return None + + # Apply static transform: quaternion [0.5, -0.5, -0.5, 0.5] + Q = Rotation.from_quat([0.5, -0.5, -0.5, 0.5]) + T_static = np.eye(4) + T_static[:3, :3] = Q.as_matrix() + + transform_ros: np.ndarray = T_static @ transform_openxr + return transform_ros + + def get_button_state(self, button_name: str) -> bool: + """Get current state of a button. + + Args: + button_name: Button name (e.g., 'A', 'B', 'X', 'Y', 'RJ', + 'LJ') + + Returns: + True if button is pressed, False otherwise + """ # noqa: DOC1, DOC2, DOC5 + with self._lock: + return self._latest_buttons.get(button_name, False) + + def get_grip_value( + self, hand: Literal["left", "right", "l", "r"] = "right" + ) -> float: + """Get the continuous grip value (analog trigger). + + Args: + hand: Which hand ('left', 'right', 'l', or 'r') + + Returns: + Float value in range [0.0, 1.0] where 0.0 is not pressed and + 1.0 is fully pressed + """ # noqa: DOC1, DOC2, DOC5 + hand_key = self._normalize_hand_key(hand) + button_name = "leftGrip" if hand_key == "l" else "rightGrip" + with self._lock: + value = self._latest_buttons.get(button_name, 0.0) + + # Handle case where value might be a tuple from parsing + if isinstance(value, tuple): + return float(value[0]) if len(value) > 0 else 0.0 + return float(value) if value else 0.0 + + def get_trigger_value( + self, hand: Literal["left", "right", "l", "r"] = "right" + ) -> float: + """Get the continuous trigger value (index finger trigger). + + Args: + hand: Which hand ('left', 'right', 'l', or 'r') + + Returns: + Float value in range [0.0, 1.0] where 0.0 is not pressed and + 1.0 is fully pressed + """ # noqa: DOC1, DOC2, DOC5 + hand_key = self._normalize_hand_key(hand) + button_name = "leftTrig" if hand_key == "l" else "rightTrig" + with self._lock: + value = self._latest_buttons.get(button_name, 0.0) + + # Handle case where value might be a tuple from parsing + if isinstance(value, tuple): + return float(value[0]) if len(value) > 0 else 0.0 + return float(value) if value else 0.0 + + def get_joystick_value( + self, hand: Literal["left", "right", "l", "r"] = "right" + ) -> tuple[float, float]: + """Get the joystick position. + + Args: + hand: Which hand ('left', 'right', 'l', or 'r') + + Returns: + Tuple (x, y) where both x and y are in range [-1.0, 1.0] + Returns (0.0, 0.0) if not available + """ # noqa: DOC1, DOC2, DOC5 + hand_key = self._normalize_hand_key(hand) + button_name = "leftJS" if hand_key == "l" else "rightJS" + with self._lock: + value = self._latest_buttons.get(button_name, (0.0, 0.0)) + + if isinstance(value, tuple) and len(value) >= 2: + return (float(value[0]), float(value[1])) + return (0.0, 0.0) + + def on(self, event: str, callback: Callable) -> None: + """Register a callback for an event. + + Available events: + - 'button_b_pressed': Called when Button B is pressed + - 'button_a_pressed': Called when Button A is pressed + - 'button_x_pressed': Called when Button X is pressed + - 'button_y_pressed': Called when Button Y is pressed + - 'button_rj_pressed': Called when Right Joystick is pressed + - 'button_lj_pressed': Called when Left Joystick is pressed + + Args: + event: Event name + callback: Function to call when event occurs + """ # noqa: DOC1, DOC2, DOC5 + # make sure the event is a valid event + if event not in self._callbacks: + raise ValueError( + f"Invalid event: {event}. Must be one of: " + f"{list(self._callbacks.keys())}" + ) + + self._callbacks[event].append(callback) + + def _validate_transform(self, matrix: np.ndarray) -> np.ndarray | None: + """Validate transformation matrix. + + Args: + matrix: 4x4 transformation matrix + + Returns: + The same matrix if valid, None if invalid + """ # noqa: DOC1, DOC2, DOC5 + if np.allclose(matrix, 0.0): + return None + + det = np.linalg.det(matrix[:3, :3]) + if abs(abs(det) - 1.0) > 0.1: + return None + + return matrix + + def _normalize_hand_key(self, hand: Literal["left", "right", "l", "r"]) -> str: + """Normalize hand identifier to 'l' or 'r'. + + Args: + hand: Hand identifier ('left', 'right', 'l', or 'r') + + Returns: + 'l' or 'r' + """ # noqa: DOC1, DOC2, DOC5 + if hand in ("left", "l"): + return "l" + elif hand in ("right", "r"): + return "r" + else: + raise ValueError( + f"Invalid hand: {hand}. Must be 'left', 'right', 'l', or 'r'" + ) + + def _handle_button_events(self, buttons: dict) -> None: + """Handle button press events and trigger callbacks. + + Args: + buttons: Dictionary of button states + """ # noqa: DOC1, DOC2, DOC5 + # Use lock to prevent race conditions when called from multiple threads + callbacks_to_trigger = [] + with self._lock: + # Check for button presses (rising edge detection) + button_map = { + "B": "button_b_pressed", + "A": "button_a_pressed", + "X": "button_x_pressed", + "Y": "button_y_pressed", + "RJ": "button_rj_pressed", + "LJ": "button_lj_pressed", + } + + for button_key, event_name in button_map.items(): + current_state = buttons.get(button_key, False) + prev_state = self._prev_button_states.get(button_key, False) + + # Rising edge detected + if current_state and not prev_state: + if not self._callbacks_locks[event_name].locked(): + self._callbacks_locks[event_name].acquire() + else: + continue + # Update state BEFORE triggering callbacks to prevent double-trigger + self._prev_button_states[button_key] = current_state + # Collect callbacks to trigger (release lock before calling to avoid blocking) + callbacks_to_trigger.extend( + [(event_name, cb) for cb in self._callbacks[event_name]] + ) + else: + self._prev_button_states[button_key] = current_state + + # Trigger callbacks outside the lock to avoid blocking other threads + for event_name, callback in callbacks_to_trigger: + try: + callback() + finally: + self._callbacks_locks[event_name].release() + + def read_logcat_by_line(self, connection: Any) -> None: + """Read logcat output line by line. + + Args: + connection: Connection to read from. + """ # noqa: DOC1, DOC2, DOC5 + file_obj = connection.socket.makefile(mode="rb", buffering=1024) + while self.running: + try: + line = file_obj.readline().decode("utf-8", errors="replace").strip() + data = self.extract_data(line) + if data: + transforms, buttons = MetaQuestReader.process_data(data) + with self._lock: + self.last_transforms, self.last_buttons = transforms, buttons + + # Update validated transforms and handle button events + if transforms is not None: + for key, matrix in transforms.items(): + validated = self._validate_transform(matrix) + if validated is not None: + self._latest_transforms[key] = validated + + if buttons is not None: + self._latest_buttons = buttons + self._handle_button_events(buttons) + + except UnicodeDecodeError as e: + eprint(f"⚠️ Unicode decode error reading logcat line: {e}") + file_obj.close() + connection.close() diff --git a/alliander_meta/src/alliander_meta/include/meta_manager.hpp b/alliander_meta/src/alliander_meta/include/meta_manager.hpp new file mode 100644 index 000000000..ff649929a --- /dev/null +++ b/alliander_meta/src/alliander_meta/include/meta_manager.hpp @@ -0,0 +1,79 @@ +// # SPDX-FileCopyrightText: Alliander N. V. +// +// # SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +typedef geometry_msgs::msg::PoseStamped PoseStamped; +typedef geometry_msgs::msg::TransformStamped TransformStamped; +typedef sensor_msgs::msg::Joy Joy; +typedef alliander_interfaces::srv::StringSrv StringSrv; +typedef moveit_msgs::srv::ServoCommandType ServoCommandType; + +/// Class to interact with a Meta Quest 3. +class MetaManager : public rclcpp::Node { + public: + MetaManager(); + + private: + /// Namespace meta: + std::string namespace_meta; + /// Namespace arm: + std::string namespace_arm; + + // TF2 + /// Buffer storing incoming transforms + std::shared_ptr tf_buffer; + /// Listener that populates tf_buffer_ + std::shared_ptr tf_listener; + /// Broadcaster used to publish the end-effector target frame + std::shared_ptr tf_broadcaster; + + // Subscriptions + /// Subscriber for joystick input from the Meta Quest controller + rclcpp::Subscription::SharedPtr sub_joystick; + + // Publishers + /// Publisher for republishing Meta Quest TF frames onto the global /tf topic: + rclcpp::Publisher::SharedPtr pub_servo_target; + + // Clients + /// Client to move the arm back to home position + rclcpp::Client::SharedPtr srv_client_arm_home; + /// Client to change the command type of the servo node + rclcpp::Client::SharedPtr + srv_client_switch_servo_command_type; + + // State + /// True when the end-effector target must be reset + bool OUTDATED = true; + /// True when the arm is currently moving using a service call + bool BUSY = false; + /// Start of the hand frame when the trigger is pressed + TransformStamped hand_start; + /// Start of the end-effector target frame when the trigger is pressed + TransformStamped end_effector_start; + + /** + * @brief Callback for incoming joystick messages from the Meta Quest. + * @param msg the incoming joystick message + */ + void callback_joystick(const Joy::SharedPtr msg); + /// Initialise the end-effector target to the robot's current link pose + void set_end_effector_target_to_current_pose(); + /// Look up the end-effector target in the map frame and publish it + void publish_servo_target(); + /// Call the service to move the robot arm to its home position + void move_arm_to_home(); + /// Call the service to switch the command type of the servo node + void switch_servo_command_type(); +}; diff --git a/alliander_meta/src/alliander_meta/launch/meta.launch.py b/alliander_meta/src/alliander_meta/launch/meta.launch.py new file mode 100644 index 000000000..41f3ecf53 --- /dev/null +++ b/alliander_meta/src/alliander_meta/launch/meta.launch.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 +import warnings + +from alliander_utilities.config_objects import PlatformList +from alliander_utilities.launch_argument import LaunchArgument +from alliander_utilities.register import Register +from launch import LaunchContext, LaunchDescription +from launch.actions import ExecuteProcess, OpaqueFunction +from launch_ros.actions import Node, SetParameter + +platform_list_arg = LaunchArgument("platform_list", "") + + +def launch_setup(context: LaunchContext) -> list: + """The launch setup. + + Args: + context (LaunchContext): The launch context. + + Returns: + list: The actions to start. + """ + platforms = PlatformList.from_str(platform_list_arg.string_value(context)).platforms + + namespace = "quest" + namespace_arm = "" + + for platform in platforms: + match platform.platform_type: + case "Arm": + if not namespace_arm: + namespace_arm = platform.namespace + else: + warnings.warn( + "No support for multiple arms yet, only accepting the first arm.", + RuntimeWarning, + stacklevel=1, + ) + case _: + pass + + meta_quest_node = Node( + package="alliander_meta", + executable="meta_quest_node.py", + namespace=namespace, + ) + + scrcpy = ExecuteProcess(cmd=["scrcpy", "--stay-awake", "--no-audio"]) + + meta_manager = Node( + package="alliander_meta", + executable="meta_manager", + namespace=namespace, + parameters=[{"namespace_arm": namespace_arm}], + ) + + return [ + SetParameter(name="use_sim_time", value=platforms[0].simulation), + Register.on_log(meta_quest_node, "Meta Quest Node initialized.", context), + Register.on_start(meta_manager, context), + Register.on_start(scrcpy, context), + ] + + +def generate_launch_description() -> LaunchDescription: + """Generate the launch description. + + Returns: + LaunchDescription: The launch description. + """ + return LaunchDescription( + [ + platform_list_arg.declaration, + OpaqueFunction(function=launch_setup), + ] + ) diff --git a/alliander_meta/src/alliander_meta/package.xml b/alliander_meta/src/alliander_meta/package.xml new file mode 100644 index 000000000..915ad35d7 --- /dev/null +++ b/alliander_meta/src/alliander_meta/package.xml @@ -0,0 +1,34 @@ + + + + + + + alliander_meta + 0.1.0 + Contains the Alliander Robotics software for the Meta Quest. + Alliander Robotics + Apache 2.0 + + ament_cmake + ament_cmake_python + + rclcpp + rclcpp_action + geometry_msgs + sensor_msgs + tf2 + tf2_msgs + tf2_ros + alliander_interfaces + + ament_lint_auto + + + ament_cmake + + diff --git a/alliander_meta/src/alliander_meta/src/meta_manager.cpp b/alliander_meta/src/alliander_meta/src/meta_manager.cpp new file mode 100644 index 000000000..c15ae23f1 --- /dev/null +++ b/alliander_meta/src/alliander_meta/src/meta_manager.cpp @@ -0,0 +1,206 @@ +// # SPDX-FileCopyrightText: Alliander N. V. +// +// # SPDX-License-Identifier: Apache-2.0 + +#include "meta_manager.hpp" + +#include +#include + +MetaManager::MetaManager() + : Node( + "meta_manager", + rclcpp::NodeOptions().automatically_declare_parameters_from_overrides( + true)) { + // Declare parameters: + try { + namespace_arm = this->get_parameter("namespace_arm").as_string(); + } catch (const rclcpp::exceptions::ParameterNotDeclaredException& e) { + RCLCPP_ERROR(this->get_logger(), "Parameter 'namespace_arm' is not set."); + } + namespace_meta = std::string(this->get_namespace()).erase(0, 1); + + // Initialize TF2: + tf_buffer = std::make_shared(this->get_clock()); + tf_listener = std::make_shared(*tf_buffer); + tf_broadcaster = std::make_shared(this); + + // Subscriptions: + sub_joystick = this->create_subscription( + "/" + namespace_meta + "/joystick", 10, + std::bind(&MetaManager::callback_joystick, this, std::placeholders::_1)); + + // Publishers: + pub_servo_target = this->create_publisher( + "/franka/servo_node/pose_target_cmds", 10); + + // Clients: + srv_client_arm_home = this->create_client( + "/" + namespace_arm + "/moveit_manager/move_to_configuration"); + srv_client_switch_servo_command_type = this->create_client( + "/" + namespace_arm + "/servo_node/switch_command_type"); +} + +// Set the hand frame to the current pose of the end-effector when the +// joystick trigger is pressed, and publish the target frame as long as the +// trigger is held down. +bool BUTTON_A_PRESSED = false; +bool GRIP_PRESSED = false; + +void MetaManager::callback_joystick(const Joy::SharedPtr msg) { + // Return if the arm is busy: + if (BUSY) { + return; + } + + auto BUTTON_A = bool(msg->buttons[0]); + auto TRIGGER = bool(msg->axes[0] == 1.0f); + auto GRIP = bool(msg->axes[1] == 1.0f); + + // Move arm to home position when button A is pressed: + if (!BUTTON_A) { + BUTTON_A_PRESSED = false; + } + if (BUTTON_A && !BUTTON_A_PRESSED && !TRIGGER) { + move_arm_to_home(); + BUTTON_A_PRESSED = true; + } + + // Switch servo command type when grip is pressed: + if (!GRIP) { + GRIP_PRESSED = false; + } + if (GRIP && !GRIP_PRESSED && !TRIGGER) { + switch_servo_command_type(); + GRIP_PRESSED = true; + } + + // Return if the trigger is not pressed: + if (!TRIGGER) { + OUTDATED = true; + return; + } + + // Update the end-effector frame if outdated: + if (OUTDATED) { + set_end_effector_target_to_current_pose(); + OUTDATED = false; + } + + // Publish start positions of hand and end-effector: + hand_start.header.stamp = this->get_clock()->now(); + tf_broadcaster->sendTransform(hand_start); + end_effector_start.header.stamp = this->get_clock()->now(); + tf_broadcaster->sendTransform(end_effector_start); + + // Publish the target: + publish_servo_target(); +} + +// Switch the servo command type to POSE: +void MetaManager::switch_servo_command_type() { + auto request = std::make_shared(); + request->command_type = 2; + + if (!srv_client_switch_servo_command_type->service_is_ready()) { + RCLCPP_WARN(this->get_logger(), + "'Switch servo command type' service not available."); + } + + srv_client_switch_servo_command_type->async_send_request( + request, [this](rclcpp::Client::SharedFuture future) { + try { + auto response = future.get(); + + if (response->success) { + RCLCPP_INFO(this->get_logger(), "Servo command type is POSE."); + } else { + RCLCPP_WARN(this->get_logger(), + "Failed to set servo command type."); + } + } catch (const std::exception& e) { + RCLCPP_ERROR(this->get_logger(), + "'Switch servo command type' service call failed: %s", + e.what()); + } + }); +} + +// Set the end-effector target to the current pose of the end-effector: +void MetaManager::set_end_effector_target_to_current_pose() { + try { + hand_start = tf_buffer->lookupTransform("map", "quest/hand_right", + tf2::TimePointZero); + hand_start.child_frame_id = "quest/hand_right_start"; + end_effector_start = tf_buffer->lookupTransform( + "map", namespace_arm + "/fr3_hand_tcp", tf2::TimePointZero); + end_effector_start.child_frame_id = "end_effector_start"; + } catch (const tf2::TransformException& e) { + RCLCPP_ERROR(this->get_logger(), "Could not get transform: %s", e.what()); + } +} + +// Publish the target frame on the tf and as pose for MoveIt Servo. +void MetaManager::publish_servo_target() { + try { + auto transform = tf_buffer->lookupTransform( + "quest/hand_right_start", "quest/hand_right", tf2::TimePointZero); + transform.header.frame_id = "end_effector_start"; + transform.child_frame_id = "end_effector_target"; + tf_broadcaster->sendTransform(transform); + + PoseStamped servo_target; + servo_target.header.frame_id = "end_effector_start"; + servo_target.pose.position.x = transform.transform.translation.x; + servo_target.pose.position.y = transform.transform.translation.y; + servo_target.pose.position.z = transform.transform.translation.z; + servo_target.pose.orientation = transform.transform.rotation; + servo_target.header.stamp = this->get_clock()->now(); + pub_servo_target->publish(servo_target); + } catch (const tf2::TransformException&) { + } +} + +// Move the arm to the home position and mark it as busy until completed: +void MetaManager::move_arm_to_home() { + if (BUSY) { + RCLCPP_INFO(this->get_logger(), "Arm already moving."); + return; + } + BUSY = true; + + auto request = std::make_shared(); + request->text = "home"; + + if (!srv_client_arm_home->service_is_ready()) { + RCLCPP_WARN(this->get_logger(), "'Move arm' service not available."); + BUSY = false; + return; + } + + srv_client_arm_home->async_send_request( + request, [this](rclcpp::Client::SharedFuture future) { + try { + auto response = future.get(); + + if (response->success) { + RCLCPP_DEBUG(this->get_logger(), "Arm moved home successfully."); + } else { + RCLCPP_WARN(this->get_logger(), "Move home failed."); + } + } catch (const std::exception& e) { + RCLCPP_ERROR(this->get_logger(), "'Move arm' service call failed: %s", + e.what()); + } + BUSY = false; + }); +} + +int main(int argc, char** argv) { + rclcpp::init(argc, argv); + auto node = std::make_shared(); + rclcpp::executors::MultiThreadedExecutor executor; + executor.add_node(node); + executor.spin(); + rclcpp::shutdown(); +} diff --git a/alliander_meta/src/alliander_meta/src_py/meta_quest_node.py b/alliander_meta/src/alliander_meta/src_py/meta_quest_node.py new file mode 100755 index 000000000..5d3cf4e33 --- /dev/null +++ b/alliander_meta/src/alliander_meta/src_py/meta_quest_node.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Alliander N. V. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Meta Quest Reader.""" + +import numpy as np +import rclpy +from alliander_meta.meta_quest_reader import MetaQuestReader +from geometry_msgs.msg import TransformStamped +from rclpy.node import Node +from scipy.spatial.transform import Rotation +from sensor_msgs.msg import Joy +from tf2_ros import TransformBroadcaster + + +class MetaQuestNode(Node): + """Node to read data from the Meta Quest.""" + + def __init__(self): + """Initialize the node.""" + super().__init__("meta_quest_node") + self.tf_broadcaster = TransformBroadcaster(self) + + self.reader = MetaQuestReader() + self.pub_joy = self.create_publisher(Joy, "/quest/joystick", 10) + + self.create_timer(0.001, self.publish_tf) + self.create_timer(0.01, self.publish_joystick) + self.get_logger().info("Meta Quest Node initialized.") + + def publish_joystick(self) -> None: + """Publish the joystick data.""" + joy = Joy() + joy.axes = [0.0] * 6 + joy.buttons = [0] * 10 + joy.axes[0] = self.reader.get_trigger_value("right") + joy.axes[1] = self.reader.get_grip_value("right") + joy.buttons[0] = self.reader.get_button_state("A") + joy.header.stamp = self.get_clock().now().to_msg() + self.pub_joy.publish(joy) + + def publish_tf(self) -> None: + """Publish the tf data.""" + transform = self.reader.get_hand_controller_transform_ros("right") + if not isinstance(transform, np.ndarray): + return + + transform_stamped = TransformStamped() + transform_stamped.header.frame_id = "map" + transform_stamped.child_frame_id = "quest/hand_right" + + transform_stamped.transform.translation.x = transform[0, 3] + transform_stamped.transform.translation.y = transform[1, 3] + transform_stamped.transform.translation.z = transform[2, 3] + + rotation = Rotation.from_matrix(transform[:3, :3]) + rotation *= Rotation.from_euler("xyz", [0, np.pi / 2, np.pi / 2]) + + quat = rotation.as_quat() + transform_stamped.transform.rotation.x = quat[0] + transform_stamped.transform.rotation.y = quat[1] + transform_stamped.transform.rotation.z = quat[2] + transform_stamped.transform.rotation.w = quat[3] + + transform_stamped.header.stamp = self.get_clock().now().to_msg() + self.tf_broadcaster.sendTransform(transform_stamped) + + +def main(args: list | None = None) -> None: + """Main function to initialize the ROS 2 node. + + Args: + args (list | None): Command line arguments, defaults to None. + """ + rclpy.init(args=args) + reader = MetaQuestNode() + rclpy.spin(reader) + reader.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/alliander_moveit/src/alliander_franka_moveit_config/config/fr3.srdf b/alliander_moveit/src/alliander_franka_moveit_config/config/fr3.srdf index 03c599ce7..4ab29e716 100644 --- a/alliander_moveit/src/alliander_franka_moveit_config/config/fr3.srdf +++ b/alliander_moveit/src/alliander_franka_moveit_config/config/fr3.srdf @@ -28,6 +28,16 @@ are defined group--> + + + + + + + + + + @@ -50,7 +60,7 @@ are defined - + @@ -59,7 +69,7 @@ are defined - + @@ -71,7 +81,7 @@ are defined - + + +# Input Devices + +This page describes the use of the different input devices we support. + +## Joystick + +Arm and Vehicle platforms can be controlled using a joystick. To do this, connect a joystick to the USB interface of the host device that will run the Alliander Robotics software stack. To activate joystick control, pass the `--joystick` or `-j` flag to the `uv run start.py` command when launching a configuration. + +## Meta Quest 3 + +Arm platforms can be controlled using a Meta Quest 3. To do this, we make use of the [meta_quest_teleop](https://github.com/NeuracoreAI/meta_quest_teleop) tool. Make sure to install the APK on the Quest and enable USB debugging if not done already. + +### Install ADB + +The tool requires Android Debug Bridge (ADB) to work. Make sure to install ADB on the host device that will run the Alliander Robotics software stack: + +```bash +sudo apt install android-tools-adb +``` + +Next, connect the Quest to the host device via USB. You should see the Quest as: + +```bash +ID XXXX:YYYY Oculus VR, Inc. Quest 3 +``` + +Where `XXXX` and `YYYY` are four numbers. Next, create an udev rule for the Quest, by adding the following line to `/etc/udev/rules.d/51-android.rules` (create this file of it does not exist yet): + +```bash +SUBSYSTEM=="usb", ATTR{idVendor}=="XXXX", ATTR{idProduct}=="YYYY", MODE="0666", GROUP="plugdev" +``` + +Make sure to replace `XXXX` and `YYYY` with the correct numbers. To reload the udev rules after this change, run: + +```bash +sudo udevadm control --reload-rules +``` + +Now, run `adb devices` and select `Always allow from this computer` on the Quest. You should see the following response: + +```bash +List of devices attached + device + +``` + +### Control an Arm platform using the Quest + +**Run ADB on host device** +\ +Make sure that the ADB deamon is already running on the host device, by checking for the expected response on the `adb devices command`. If the ADB deamon is not running on the host device, the docker container will launch an ADB deamon. The Quest sees this as a different computer, asking again for USB debugging alowence, failing the Docker container to start. + +**Run the app and keep Quest on** +\ +Next, start the meta_quest_teleop app if not already running. Note that by default, the Quest turns off when you do are not wearing it. To avoid this, you can turn off the proximity sensor using: + +```bash +adb shell am broadcast -a com.oculus.vrpowermanager.prox_close +``` + +You can enable the proximity sensor again using: + +```bash +shell am broadcast -a com.oculus.vrpowermanager.automation_disable +``` + +**Start the software stack** +\ +To activate Meta Quest control, pass the `--meta` or `-m` flag to the `uv run start.py` command when launching a configuration. When an Arm platform with MoveIt Servo is started, you should be able to switch MoveIt Servo to pose tracking by pressing the Grip button on the side of the right Controller. + +Make sure that the Controller is always in view of the Quest Headset and place the Headset statically in the room. The app measures the Controller position relative to the Quest Headset, so movement of the Headset results in Controller movements for the system. + +Finally, when you hold the Trigger on the right Controller, the arm should copy the movements made with the Controller. To Home the arm, press the A button. + +:::{video} ../vid/input_devices/quest_control.mp4 +:width: 100% +::: diff --git a/docs/index.rst b/docs/index.rst index 68999b5ca..faeee2d3f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,6 +19,7 @@ content/getting_started.md content/system.md content/platforms.md + content/input_devices.md content/moveit.md content/grasping.md diff --git a/docs/vid/input_devices/quest_control.mp4 b/docs/vid/input_devices/quest_control.mp4 new file mode 100644 index 000000000..3b3c89aa8 --- /dev/null +++ b/docs/vid/input_devices/quest_control.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92f2d91234dd90035dce40598e94802347a4f188d679129d5156c855b3eead3f +size 1233879 diff --git a/docs/vid/input_devices/quest_control.mp4.license b/docs/vid/input_devices/quest_control.mp4.license new file mode 100644 index 000000000..b4d18b041 --- /dev/null +++ b/docs/vid/input_devices/quest_control.mp4.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Alliander N. V. + +SPDX-License-Identifier: Apache-2.0 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9036bfb06..3bb6023e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,11 @@ alliander-gazebo = [ alliander-gps = [ "pydantic>=2.12.5", ] +alliander-meta = [ + "numpy>=2.4.1", + "pure-python-adb-reborn>=1.0.0", + "scipy>=1.17.0" +] alliander-moveit = [ "xmltodict>=1.0.2", ] diff --git a/start.py b/start.py index ba4ca31d7..b38fc8c85 100755 --- a/start.py +++ b/start.py @@ -28,6 +28,7 @@ "linting", "documentation", "joystick", + "meta", "diagnostics", ] MODE = typing.Literal[ @@ -74,6 +75,7 @@ def __init__(self, ros_domain_id: int = 0) -> None: self.dev = False self.gazebo_ui = False self.joystick = False + self.meta = False self.rviz_yaml = False self.ros_domain_id = ros_domain_id @@ -171,6 +173,11 @@ def get_service_config( f" platform_list:='{self.predefined_configuration.plat_conf.to_str()}'", {}, ), + "meta": ( + "alliander_meta", + f" platform_list:='{self.predefined_configuration.plat_conf.to_str()}'", + {}, + ), "diagnostics": ( "alliander_diagnostics", ( @@ -392,6 +399,8 @@ def create_compose( # noqa: PLR0912 services["alliander_visualization"]["depends_on"] = {} if self.joystick: self.add_service(content, "joystick") + if self.meta: + self.add_service(content, "meta") # Add healthchecks to all services: for name, service in services.items(): @@ -400,6 +409,14 @@ def create_compose( # noqa: PLR0912 "interval": "1s", "retries": 1000, } + # Make all services depend on meta, if meta is enabled: + if self.meta and name != "alliander_meta": + if "depends_on" not in service: + service["depends_on"] = {} + service["depends_on"]["alliander_meta"] = { + "condition": "service_healthy" + } + # Make visualization depenend on all other services: if ( "alliander_visualization" in services @@ -528,6 +545,14 @@ def run_compose(self) -> int: help="Add this flag to enable joystick control for arm and/or vehicle platforms.", ) + parser.add_argument( + "-m", + "--meta", + required=False, + action="store_true", + help="Add this flag to enable Meta Quest control for arm platforms.", + ) + parser.add_argument( "--rviz", required=False, @@ -555,6 +580,7 @@ def run_compose(self) -> int: compose.simulator = not args.hardware compose.visualization = args.visualization compose.joystick = args.joystick + compose.meta = args.meta compose.rviz_yaml = args.rviz compose.mode = "configuration" elif isinstance(args.pytest, list): diff --git a/uv.lock b/uv.lock index a00d48dda..b4142e093 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = "==3.12.*" [[package]] @@ -101,6 +101,11 @@ alliander-gazebo = [ alliander-gps = [ { name = "pydantic" }, ] +alliander-meta = [ + { name = "numpy" }, + { name = "pure-python-adb-reborn" }, + { name = "scipy" }, +] alliander-moveit = [ { name = "xmltodict" }, ] @@ -162,6 +167,11 @@ alliander-gazebo = [ { name = "xmltodict", specifier = ">=1.0.2" }, ] alliander-gps = [{ name = "pydantic", specifier = ">=2.12.5" }] +alliander-meta = [ + { name = "numpy", specifier = ">=2.4.1" }, + { name = "pure-python-adb-reborn", specifier = ">=1.0.0" }, + { name = "scipy", specifier = ">=1.17.0" }, +] alliander-moveit = [{ name = "xmltodict", specifier = ">=1.0.2" }] alliander-nav2 = [ { name = "numpy", specifier = ">=2.4.1" }, @@ -223,33 +233,80 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "babel" -version = "2.17.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, ] [[package]] @@ -261,6 +318,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, ] +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + [[package]] name = "catkin-pkg" version = "1.1.0" @@ -279,11 +358,34 @@ wheels = [ [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, ] [[package]] @@ -297,39 +399,39 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -364,12 +466,51 @@ wheels = [ ] [[package]] -name = "cycler" -version = "0.12.1" +name = "configparser" +version = "7.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/ac/ea19242153b5e8be412a726a70e82c7b5c1537c83f61b20995b2eda3dcd7/configparser-7.2.0.tar.gz", hash = "sha256:b629cc8ae916e3afbd36d1b3d093f34193d851e11998920fdcfc4552218b7b70", size = 51273, upload-time = "2025-03-08T16:04:09.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, + { url = "https://files.pythonhosted.org/packages/09/fe/f61e7129e9e689d9e40bbf8a36fb90f04eceb477f4617c02c6a18463e81f/configparser-7.2.0-py3-none-any.whl", hash = "sha256:fee5e1f3db4156dcd0ed95bc4edfa3580475537711f67a819c966b389d09ce62", size = 17232, upload-time = "2025-03-08T16:04:07.743Z" }, +] + +[[package]] +name = "cryptography" +version = "47.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, ] [[package]] @@ -399,28 +540,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "elementpath" +version = "5.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/95/eeb61a2a917bf506d1402748e71c62399d8dcdd8cdccd64c81962832c260/elementpath-5.1.1.tar.gz", hash = "sha256:c4d1bd6aed987258354d0ea004d965eb0a6818213326bd4fd9bde5dacdb20277", size = 375378, upload-time = "2026-01-20T21:42:27.175Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/5a/4ddfd9aeecdc75a78b5d85d882abc2b822115caae2c00a4d0eb23cc101fc/elementpath-5.1.1-py3-none-any.whl", hash = "sha256:57637359065e60654422d8474c1749b09bb21348012b7197f868027e3c09c9b9", size = 259962, upload-time = "2026-01-20T21:42:24.127Z" }, +] + +[[package]] +name = "exscript" +version = "2.6.32" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configparser" }, + { name = "future" }, + { name = "paramiko" }, + { name = "pycryptodomex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/89/cf65ea15667ea2b110ccd67db009e4193681fc1dd4ba56051b93e67fe82d/exscript-2.6.32.tar.gz", hash = "sha256:8818c9664fa26166bedea8dd54bc452b051556f8b9c43db07e9ab720e8c4310c", size = 131307, upload-time = "2025-08-05T16:13:20.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/e7/247f62c0adf1f4c43260558a1c78b85d03f3435c9796ec9d8c0a48de2ce6/exscript-2.6.32-py2.py3-none-any.whl", hash = "sha256:11ec6f77e2e06c02221b427ea3a7019b56b4e18b858de8c052b8f467eea3e5a0", size = 255368, upload-time = "2025-08-05T16:13:18.853Z" }, +] + [[package]] name = "fastapi" -version = "0.128.0" +version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] [[package]] name = "filelock" -version = "3.20.3" +version = "3.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] [[package]] @@ -490,6 +656,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "future" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -542,22 +717,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "hypothesis" +version = "6.152.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/c7/3147bd903d6b18324a016d43a259cf5b4bb4545e1ead6773dc8a0374e70a/hypothesis-6.152.4.tar.gz", hash = "sha256:31c8f9ce619716f543e2710b489b1633c833586641d9e6c94cee03f109a5afc4", size = 466444, upload-time = "2026-04-27T20:18:37.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/89/0f50dd0d92e8a7dffc24f69ab910ff81db89b2f082ba42682bd57695e4d2/hypothesis-6.152.4-py3-none-any.whl", hash = "sha256:e730fd93c7578182efadc7f90b3c5437ee4d55edf738930eb5043c81ac1d97e8", size = 532145, upload-time = "2026-04-27T20:18:35.043Z" }, +] + [[package]] name = "identify" -version = "2.6.16" +version = "2.6.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]] @@ -571,11 +758,11 @@ wheels = [ [[package]] name = "imagesize" -version = "1.4.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] [[package]] @@ -587,6 +774,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "invoke" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -696,11 +892,11 @@ wheels = [ [[package]] name = "markdown2" -version = "2.5.4" +version = "2.5.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/42/f8/b2ae8bf5f28f9b510ae097415e6e4cb63226bb28d7ee01aec03a755ba03b/markdown2-2.5.4.tar.gz", hash = "sha256:a09873f0b3c23dbfae589b0080587df52ad75bb09a5fa6559147554736676889", size = 145652, upload-time = "2025-07-27T16:16:24.307Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/07d4a5fcaa5509221287d289323d75ac8eda5a5a4ac9de2accf7bbcc2b88/markdown2-2.5.5.tar.gz", hash = "sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664", size = 157249, upload-time = "2026-03-02T20:46:53.411Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/06/2697b5043c3ecb720ce0d243fc7cf5024c0b5b1e450506e9b21939019963/markdown2-2.5.4-py3-none-any.whl", hash = "sha256:3c4b2934e677be7fec0e6f2de4410e116681f4ad50ec8e5ba7557be506d3f439", size = 49954, upload-time = "2025-07-27T16:16:23.026Z" }, + { url = "https://files.pythonhosted.org/packages/43/af/4b3891eb0a49d6cfd5cbf3e9bf514c943afc2b0f13e2c57cc57cd88ecc21/markdown2-2.5.5-py3-none-any.whl", hash = "sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941", size = 56250, upload-time = "2026-03-02T20:46:52.032Z" }, ] [[package]] @@ -724,14 +920,14 @@ wheels = [ [[package]] name = "mashumaro" -version = "3.17" +version = "3.20" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/67/c4e235256baf6837106d2620c7123eb1e5786c704c7f7d7fa488ad6afc61/mashumaro-3.17.tar.gz", hash = "sha256:de1d8b1faffee58969c7f97e35963a92480a38d4c9858e92e0721efec12258ed", size = 189877, upload-time = "2025-10-03T21:09:27.281Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/3d/0f1bf475109a816c2a31a8b424750911343f0bce304827a5255df167547e/mashumaro-3.20.tar.gz", hash = "sha256:af4573f14ae61be3fbc3a473158ddfc1420f345410385809fd782e0d79e9215c", size = 191643, upload-time = "2026-02-09T21:53:55.24Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/2d/edc147aa1dce9e0e377687d453e62fdfcbccc08cd35d0b7413e3485c4b92/mashumaro-3.17-py3-none-any.whl", hash = "sha256:3964e2c804f62de9e4c58fb985de71dcd716f9507cc18374b1bd5c4f1a1b879b", size = 94198, upload-time = "2025-10-03T21:09:25.436Z" }, + { url = "https://files.pythonhosted.org/packages/96/5a/4fed77781061647d3be98e2f235ef1869289dd839ca0451a8d50a30fcd5c/mashumaro-3.20-py3-none-any.whl", hash = "sha256:648bc326f64c55447988eab67d6bfe3b7958c0961c83590709b1f950f88f4a3c", size = 94942, upload-time = "2026-02-09T21:53:53.343Z" }, ] [[package]] @@ -790,31 +986,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mock" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/8c/14c2ae915e5f9dca5a22edd68b35be94400719ccfa068a03e0fb63d0f6f6/mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0", size = 92796, upload-time = "2025-03-03T12:31:42.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/d9/617e6af809bf3a1d468e0d58c3997b1dc219a9a9202e650d30c2fc85d481/mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f", size = 31617, upload-time = "2025-03-03T12:31:41.518Z" }, +] + [[package]] name = "multidict" -version = "6.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, - { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, - { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, - { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, - { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, - { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, - { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, - { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, - { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] @@ -836,7 +1050,7 @@ wheels = [ [[package]] name = "nicegui" -version = "3.10.0" +version = "3.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -849,22 +1063,25 @@ dependencies = [ { name = "ifaddr" }, { name = "itsdangerous" }, { name = "jinja2" }, + { name = "lxml" }, { name = "lxml-html-clean" }, { name = "markdown2" }, { name = "orjson", marker = "platform_machine != 'i386' and platform_machine != 'i686' and platform_python_implementation != 'PyPy'" }, { name = "pydantic-core" }, { name = "pygments" }, + { name = "python-dotenv" }, { name = "python-engineio" }, { name = "python-multipart" }, { name = "python-socketio", extra = ["asyncio-client"] }, { name = "starlette" }, + { name = "tinycss2" }, { name = "typing-extensions" }, { name = "uvicorn", extra = ["standard"] }, { name = "watchfiles" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/df/736d1e17db943d2a8425de22a69284b0374b8ed17efdcde10a5ba6d181f8/nicegui-3.10.0.tar.gz", hash = "sha256:10bca0ed1957c91506e54e02a8d2ad8860777e121fb54bfe59797493ba87ec14", size = 19147081, upload-time = "2026-04-07T09:27:33.09Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/5f/c03d3d9253c3e67b560749ad344dd74de61bba3c35788a029ad9ad0f7a4f/nicegui-3.11.1.tar.gz", hash = "sha256:d6d176c458a012fff926faf196f6cb3eb86c22203eea4aa335a4d0162291e92c", size = 19231328, upload-time = "2026-04-25T20:00:57.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/44/73cd2dda7bd46a903751ea3fd93624ac3a97afdfb6a02e0aead6ee4cb0d1/nicegui-3.10.0-py3-none-any.whl", hash = "sha256:0c7f084b1c9036e645ce43727ac354d89214d355dededb25adc3ba2f4d08aaa3", size = 19711706, upload-time = "2026-04-07T09:27:29.203Z" }, + { url = "https://files.pythonhosted.org/packages/33/44/350aed50d86abbd192cfc4e4b7bd3d7ba7c50a7cde7dc9e9a7c57f8799a8/nicegui-3.11.1-py3-none-any.whl", hash = "sha256:7e0e63e86dc35ece491aa5dd00a0ff8c28b443b0d58e48b0f425690e1578cc54", size = 19801682, upload-time = "2026-04-25T20:01:01.802Z" }, ] [[package]] @@ -878,53 +1095,77 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.1" +version = "2.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, - { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, - { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, - { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, - { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, ] [[package]] name = "orjson" -version = "3.11.6" +version = "3.11.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/a3/4e09c61a5f0c521cba0bb433639610ae037437669f1a4cbc93799e731d78/orjson-3.11.6.tar.gz", hash = "sha256:0a54c72259f35299fd033042367df781c2f66d10252955ca1efb7db309b954cb", size = 6175856, upload-time = "2026-01-29T15:13:07.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/ba/759f2879f41910b7e5e0cdbd9cf82a4f017c527fb0e972e9869ca7fe4c8e/orjson-3.11.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6f03f30cd8953f75f2a439070c743c7336d10ee940da918d71c6f3556af3ddcf", size = 249988, upload-time = "2026-01-29T15:11:58.294Z" }, - { url = "https://files.pythonhosted.org/packages/f0/70/54cecb929e6c8b10104fcf580b0cc7dc551aa193e83787dd6f3daba28bb5/orjson-3.11.6-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:af44baae65ef386ad971469a8557a0673bb042b0b9fd4397becd9c2dfaa02588", size = 134445, upload-time = "2026-01-29T15:11:59.819Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6f/ec0309154457b9ba1ad05f11faa4441f76037152f75e1ac577db3ce7ca96/orjson-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c310a48542094e4f7dbb6ac076880994986dda8ca9186a58c3cb70a3514d3231", size = 137708, upload-time = "2026-01-29T15:12:01.488Z" }, - { url = "https://files.pythonhosted.org/packages/20/52/3c71b80840f8bab9cb26417302707b7716b7d25f863f3a541bcfa232fe6e/orjson-3.11.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8dfa7a5d387f15ecad94cb6b2d2d5f4aeea64efd8d526bfc03c9812d01e1cc0", size = 134798, upload-time = "2026-01-29T15:12:02.705Z" }, - { url = "https://files.pythonhosted.org/packages/30/51/b490a43b22ff736282360bd02e6bded455cf31dfc3224e01cd39f919bbd2/orjson-3.11.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba8daee3e999411b50f8b50dbb0a3071dd1845f3f9a1a0a6fa6de86d1689d84d", size = 140839, upload-time = "2026-01-29T15:12:03.956Z" }, - { url = "https://files.pythonhosted.org/packages/95/bc/4bcfe4280c1bc63c5291bb96f98298845b6355da2226d3400e17e7b51e53/orjson-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89d104c974eafd7436d7a5fdbc57f7a1e776789959a2f4f1b2eab5c62a339f4", size = 144080, upload-time = "2026-01-29T15:12:05.151Z" }, - { url = "https://files.pythonhosted.org/packages/01/74/22970f9ead9ab1f1b5f8c227a6c3aa8d71cd2c5acd005868a1d44f2362fa/orjson-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2e2e2456788ca5ea75616c40da06fc885a7dc0389780e8a41bf7c5389ba257b", size = 142435, upload-time = "2026-01-29T15:12:06.641Z" }, - { url = "https://files.pythonhosted.org/packages/29/34/d564aff85847ab92c82ee43a7a203683566c2fca0723a5f50aebbe759603/orjson-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a42efebc45afabb1448001e90458c4020d5c64fbac8a8dc4045b777db76cb5a", size = 145631, upload-time = "2026-01-29T15:12:08.351Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ef/016957a3890752c4aa2368326ea69fa53cdc1fdae0a94a542b6410dbdf52/orjson-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71b7cbef8471324966c3738c90ba38775563ef01b512feb5ad4805682188d1b9", size = 147058, upload-time = "2026-01-29T15:12:10.023Z" }, - { url = "https://files.pythonhosted.org/packages/56/cc/9a899c3972085645b3225569f91a30e221f441e5dc8126e6d060b971c252/orjson-3.11.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f8515e5910f454fe9a8e13c2bb9dc4bae4c1836313e967e72eb8a4ad874f0248", size = 421161, upload-time = "2026-01-29T15:12:11.308Z" }, - { url = "https://files.pythonhosted.org/packages/21/a8/767d3fbd6d9b8fdee76974db40619399355fd49bf91a6dd2c4b6909ccf05/orjson-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:300360edf27c8c9bf7047345a94fddf3a8b8922df0ff69d71d854a170cb375cf", size = 155757, upload-time = "2026-01-29T15:12:12.776Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0b/205cd69ac87e2272e13ef3f5f03a3d4657e317e38c1b08aaa2ef97060bbc/orjson-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:caaed4dad39e271adfadc106fab634d173b2bb23d9cf7e67bd645f879175ebfc", size = 147446, upload-time = "2026-01-29T15:12:14.166Z" }, - { url = "https://files.pythonhosted.org/packages/de/c5/dd9f22aa9f27c54c7d05cc32f4580c9ac9b6f13811eeb81d6c4c3f50d6b1/orjson-3.11.6-cp312-cp312-win32.whl", hash = "sha256:955368c11808c89793e847830e1b1007503a5923ddadc108547d3b77df761044", size = 139717, upload-time = "2026-01-29T15:12:15.7Z" }, - { url = "https://files.pythonhosted.org/packages/23/a1/e62fc50d904486970315a1654b8cfb5832eb46abb18cd5405118e7e1fc79/orjson-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:2c68de30131481150073d90a5d227a4a421982f42c025ecdfb66157f9579e06f", size = 136711, upload-time = "2026-01-29T15:12:17.055Z" }, - { url = "https://files.pythonhosted.org/packages/04/3d/b4fefad8bdf91e0fe212eb04975aeb36ea92997269d68857efcc7eb1dda3/orjson-3.11.6-cp312-cp312-win_arm64.whl", hash = "sha256:65dfa096f4e3a5e02834b681f539a87fbe85adc82001383c0db907557f666bfc", size = 135212, upload-time = "2026-01-29T15:12:18.3Z" }, + { url = "https://files.pythonhosted.org/packages/01/f6/8d58b32ab32d9215973a1688aebd098252ee8af1766c0e4e36e7831f0295/orjson-3.11.8-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1cd0b77e77c95758f8e1100139844e99f3ccc87e71e6fc8e1c027e55807c549f", size = 229233, upload-time = "2026-03-31T16:15:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/2ffe35e71f6b92622e8ea4607bf33ecf7dfb51b3619dcfabfd36cbe2d0a5/orjson-3.11.8-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6a3d159d5ffa0e3961f353c4b036540996bf8b9697ccc38261c0eac1fd3347a6", size = 128772, upload-time = "2026-03-31T16:15:14.237Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/1f8682ae50d5c6897a563cb96bc106da8c9cb5b7b6e81a52e4cc086679b9/orjson-3.11.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76070a76e9c5ae661e2d9848f216980d8d533e0f8143e6ed462807b242e3c5e8", size = 131946, upload-time = "2026-03-31T16:15:15.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/5500f76f0eece84226e0689cb48dcde081104c2fa6e2483d17ca13685ffb/orjson-3.11.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54153d21520a71a4c82a0dbb4523e468941d549d221dc173de0f019678cf3813", size = 130368, upload-time = "2026-03-31T16:15:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/da/4e/58b927e08fbe9840e6c920d9e299b051ea667463b1f39a56e668669f8508/orjson-3.11.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:469ac2125611b7c5741a0b3798cd9e5786cbad6345f9f400c77212be89563bec", size = 135540, upload-time = "2026-03-31T16:15:18.404Z" }, + { url = "https://files.pythonhosted.org/packages/56/7c/ba7cb871cba1bcd5cd02ee34f98d894c6cea96353ad87466e5aef2429c60/orjson-3.11.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14778ffd0f6896aa613951a7fbf4690229aa7a543cb2bfbe9f358e08aafa9546", size = 146877, upload-time = "2026-03-31T16:15:19.833Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/eb9c25fc1386696c6a342cd361c306452c75e0b55e86ad602dd4827a7fd7/orjson-3.11.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea56a955056a6d6c550cf18b3348656a9d9a4f02e2d0c02cabf3c73f1055d506", size = 132837, upload-time = "2026-03-31T16:15:21.282Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/5ddeb7fc1fbd9004aeccab08426f34c81a5b4c25c7061281862b015fce2b/orjson-3.11.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53a0f57e59a530d18a142f4d4ba6dfc708dc5fdedce45e98ff06b44930a2a48f", size = 133624, upload-time = "2026-03-31T16:15:22.641Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/90048793db94ee4b2fcec4ac8e5ddb077367637d6650be896b3494b79bb7/orjson-3.11.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b48e274f8824567d74e2158199e269597edf00823a1b12b63d48462bbf5123e", size = 141904, upload-time = "2026-03-31T16:15:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cf/eb284847487821a5d415e54149a6449ba9bfc5872ce63ab7be41b8ec401c/orjson-3.11.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3f262401086a3960586af06c054609365e98407151f5ea24a62893a40d80dbbb", size = 423742, upload-time = "2026-03-31T16:15:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/44/09/e12423d327071c851c13e76936f144a96adacfc037394dec35ac3fc8d1e8/orjson-3.11.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e8c6218b614badf8e229b697865df4301afa74b791b6c9ade01d19a9953a942", size = 147806, upload-time = "2026-03-31T16:15:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6d/37c2589ba864e582ffe7611643314785c6afb1f83c701654ef05daa8fcc7/orjson-3.11.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093d489fa039ddade2db541097dbb484999fcc65fc2b0ff9819141e2ab364f25", size = 136485, upload-time = "2026-03-31T16:15:29.749Z" }, + { url = "https://files.pythonhosted.org/packages/be/c9/135194a02ab76b04ed9a10f68624b7ebd238bbe55548878b11ff15a0f352/orjson-3.11.8-cp312-cp312-win32.whl", hash = "sha256:e0950ed1bcb9893f4293fd5c5a7ee10934fbf82c4101c70be360db23ce24b7d2", size = 131966, upload-time = "2026-03-31T16:15:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/9796f8fbe3cf30ce9cb696748dbb535e5c87be4bf4fe2e9ca498ef1fa8cf/orjson-3.11.8-cp312-cp312-win_amd64.whl", hash = "sha256:3cf17c141617b88ced4536b2135c552490f07799f6ad565948ea07bef0dcb9a6", size = 127441, upload-time = "2026-03-31T16:15:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/cc/47/5aaf54524a7a4a0dd09dd778f3fa65dd2108290615b652e23d944152bc8e/orjson-3.11.8-cp312-cp312-win_arm64.whl", hash = "sha256:48854463b0572cc87dac7d981aa72ed8bf6deedc0511853dc76b8bbd5482d36d", size = 127364, upload-time = "2026-03-31T16:15:34.748Z" }, ] [[package]] name = "packaging" -version = "25.0" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "paramiko" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "invoke" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -948,11 +1189,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.9.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] [[package]] @@ -966,7 +1207,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.5.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -975,9 +1216,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] @@ -1004,6 +1245,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pure-python-adb-reborn" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "black" }, + { name = "exscript" }, + { name = "pytest", extra = ["dev"] }, + { name = "ruff" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/d6/a7c1f2df67ccb7f4bbf55a3ce7f8a4aa32502aead135f2dd49a51f336f7c/pure_python_adb_reborn-1.0.0.tar.gz", hash = "sha256:131a45b860c3bb7e472ddbf8d868269bf28cd9d6913bcc1ac61ed5efc621665d", size = 97454, upload-time = "2025-03-08T21:02:19.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/0c/8a53f96aebe0993f17738091e05f29273eb4c96e1be5cc1989b28495da20/pure_python_adb_reborn-1.0.0-py3-none-any.whl", hash = "sha256:b539bd37d7841e41a532bc040b4b66f149893a5632c571d63b5a2d48a88734a4", size = 100245, upload-time = "2025-03-08T21:02:18.307Z" }, +] + [[package]] name = "pycodestyle" version = "2.14.0" @@ -1013,9 +1270,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, +] + [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1023,38 +1308,39 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, ] [[package]] @@ -1088,13 +1374,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pyparsing" -version = "3.3.1" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512, upload-time = "2025-12-23T03:14:04.391Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] [[package]] @@ -1142,6 +1451,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[package.optional-dependencies] +dev = [ + { name = "argcomplete" }, + { name = "attrs" }, + { name = "hypothesis" }, + { name = "mock" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "xmlschema" }, +] + [[package]] name = "pytest-timeout" version = "2.4.0" @@ -1166,47 +1486,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, +] + [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-engineio" -version = "4.13.0" +version = "4.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "simple-websocket" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/5a/349caac055e03ef9e56ed29fa304846063b1771ee54ab8132bf98b29491e/python_engineio-4.13.0.tar.gz", hash = "sha256:f9c51a8754d2742ba832c24b46ed425fdd3064356914edd5a1e8ffde76ab7709", size = 92194, upload-time = "2025-12-24T22:38:05.111Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/74/c655a6eda0fd188d490c14142a0f0380655ac7099604e1fbf8fa1a97f0a1/python_engineio-4.13.0-py3-none-any.whl", hash = "sha256:57b94eac094fa07b050c6da59f48b12250ab1cd920765f4849963e3d89ad9de3", size = 59676, upload-time = "2025-12-24T22:38:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" }, ] [[package]] name = "python-multipart" -version = "0.0.26" +version = "0.0.27" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, + { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, ] [[package]] name = "python-socketio" -version = "5.16.0" +version = "5.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bidict" }, { name = "python-engineio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/55/5d8af5884283b58e4405580bcd84af1d898c457173c708736e065f10ca4a/python_socketio-5.16.0.tar.gz", hash = "sha256:f79403c7f1ba8b84460aa8fe4c671414c8145b21a501b46b676f3740286356fd", size = 127120, upload-time = "2025-12-24T23:51:48.826Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/d2/2ccc2b69a187b80fda3152745670cfba936704f296a9fa54c6c8ac694d12/python_socketio-5.16.0-py3-none-any.whl", hash = "sha256:d95802961e15c7bd54ecf884c6e7644f81be8460f0a02ee66b473df58088ee8a", size = 79607, upload-time = "2025-12-24T23:51:47.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" }, ] [package.optional-dependencies] @@ -1214,6 +1547,20 @@ asyncio-client = [ { name = "aiohttp" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1234,7 +1581,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.0" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1242,9 +1589,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] @@ -1258,58 +1605,57 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504, upload-time = "2026-01-15T20:15:16.918Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418, upload-time = "2026-01-15T20:14:50.779Z" }, - { url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344, upload-time = "2026-01-15T20:15:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720, upload-time = "2026-01-15T20:15:09.854Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493, upload-time = "2026-01-15T20:15:20.908Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174, upload-time = "2026-01-15T20:15:05.74Z" }, - { url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909, upload-time = "2026-01-15T20:15:14.537Z" }, - { url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215, upload-time = "2026-01-15T20:15:22.886Z" }, - { url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067, upload-time = "2026-01-15T20:14:48.271Z" }, - { url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916, upload-time = "2026-01-15T20:14:57.357Z" }, - { url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207, upload-time = "2026-01-15T20:14:55.111Z" }, - { url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686, upload-time = "2026-01-15T20:14:52.841Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837, upload-time = "2026-01-15T20:15:18.921Z" }, - { url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867, upload-time = "2026-01-15T20:14:59.272Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528, upload-time = "2026-01-15T20:15:03.732Z" }, - { url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242, upload-time = "2026-01-15T20:15:11.918Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680", size = 12919024, upload-time = "2026-01-15T20:14:43.647Z" }, - { url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef", size = 14097887, upload-time = "2026-01-15T20:15:01.48Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" }, +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] name = "scipy" -version = "1.17.0" +version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/11/7241a63e73ba5a516f1930ac8d5b44cbbfabd35ac73a2d08ca206df007c4/scipy-1.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0d5018a57c24cb1dd828bcf51d7b10e65986d549f52ef5adb6b4d1ded3e32a57", size = 31364580, upload-time = "2026-01-10T21:25:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1d/5057f812d4f6adc91a20a2d6f2ebcdb517fdbc87ae3acc5633c9b97c8ba5/scipy-1.17.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:88c22af9e5d5a4f9e027e26772cc7b5922fab8bcc839edb3ae33de404feebd9e", size = 27969012, upload-time = "2026-01-10T21:25:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/e3/21/f6ec556c1e3b6ec4e088da667d9987bb77cc3ab3026511f427dc8451187d/scipy-1.17.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3cd947f20fe17013d401b64e857c6b2da83cae567adbb75b9dcba865abc66d8", size = 20140691, upload-time = "2026-01-10T21:25:34.802Z" }, - { url = "https://files.pythonhosted.org/packages/7a/fe/5e5ad04784964ba964a96f16c8d4676aa1b51357199014dce58ab7ec5670/scipy-1.17.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e8c0b331c2c1f531eb51f1b4fc9ba709521a712cce58f1aa627bc007421a5306", size = 22463015, upload-time = "2026-01-10T21:25:39.277Z" }, - { url = "https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5194c445d0a1c7a6c1a4a4681b6b7c71baad98ff66d96b949097e7513c9d6742", size = 32724197, upload-time = "2026-01-10T21:25:44.084Z" }, - { url = "https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9eeb9b5f5997f75507814ed9d298ab23f62cf79f5a3ef90031b1ee2506abdb5b", size = 35009148, upload-time = "2026-01-10T21:25:50.591Z" }, - { url = "https://files.pythonhosted.org/packages/af/07/07dec27d9dc41c18d8c43c69e9e413431d20c53a0339c388bcf72f353c4b/scipy-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:40052543f7bbe921df4408f46003d6f01c6af109b9e2c8a66dd1cf6cf57f7d5d", size = 34798766, upload-time = "2026-01-10T21:25:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/81/61/0470810c8a093cdacd4ba7504b8a218fd49ca070d79eca23a615f5d9a0b0/scipy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0cf46c8013fec9d3694dc572f0b54100c28405d55d3e2cb15e2895b25057996e", size = 37405953, upload-time = "2026-01-10T21:26:07.75Z" }, - { url = "https://files.pythonhosted.org/packages/92/ce/672ed546f96d5d41ae78c4b9b02006cedd0b3d6f2bf5bb76ea455c320c28/scipy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:0937a0b0d8d593a198cededd4c439a0ea216a3f36653901ea1f3e4be949056f8", size = 36328121, upload-time = "2026-01-10T21:26:16.509Z" }, - { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, ] [[package]] name = "setuptools" -version = "80.9.0" +version = "82.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] [[package]] @@ -1359,6 +1705,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sphinx" version = "9.1.0" @@ -1480,16 +1835,16 @@ wheels = [ [[package]] name = "sphinxcontrib-mermaid" -version = "2.0.0" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "pyyaml" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/a5/65a5c439cc14ba80483b9891e9350f11efb80cd3bdccb222f0c738068c78/sphinxcontrib_mermaid-2.0.0.tar.gz", hash = "sha256:cf4f7d453d001132eaba5d1fdf53d42049f02e913213cf8337427483bfca26f4", size = 18194, upload-time = "2026-01-13T17:13:42.563Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/ae/999891de292919b66ea34f2c22fc22c9be90ab3536fbc0fca95716277351/sphinxcontrib_mermaid-2.0.1.tar.gz", hash = "sha256:a21a385a059a6cafd192aa3a586b14bf5c42721e229db67b459dc825d7f0a497", size = 19839, upload-time = "2026-03-05T14:10:41.901Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/de/bd96c69b62e967bffd02c6d89dfca9471b04e761c466725fc39746abf41d/sphinxcontrib_mermaid-2.0.0-py3-none-any.whl", hash = "sha256:59a73249bbee2c74b1a4db036f8e8899ade65982bdda6712cf22b4f4e9874bb5", size = 14055, upload-time = "2026-01-13T17:13:41.481Z" }, + { url = "https://files.pythonhosted.org/packages/03/46/25d64bcd7821c8d6f1080e1c43d5fcdfc442a18f759a230b5ccdc891093e/sphinxcontrib_mermaid-2.0.1-py3-none-any.whl", hash = "sha256:9dca7fbe827bad5e7e2b97c4047682cfd26e3e07398cfdc96c7a8842ae7f06e7", size = 14064, upload-time = "2026-03-05T14:10:40.533Z" }, ] [[package]] @@ -1524,15 +1879,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.50.0" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] @@ -1544,6 +1899,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + [[package]] name = "tornado" version = "6.5.5" @@ -1563,27 +1930,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/78/ba1a4ad403c748fbba8be63b7e774a90e80b67192f6443d624c64fe4aaab/ty-0.0.12.tar.gz", hash = "sha256:cd01810e106c3b652a01b8f784dd21741de9fdc47bd595d02c122a7d5cefeee7", size = 4981303, upload-time = "2026-01-14T22:30:48.537Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/8f/c21314d074dda5fb13d3300fa6733fd0d8ff23ea83a721818740665b6314/ty-0.0.12-py3-none-linux_armv6l.whl", hash = "sha256:eb9da1e2c68bd754e090eab39ed65edf95168d36cbeb43ff2bd9f86b4edd56d1", size = 9614164, upload-time = "2026-01-14T22:30:44.016Z" }, - { url = "https://files.pythonhosted.org/packages/09/28/f8a4d944d13519d70c486e8f96d6fa95647ac2aa94432e97d5cfec1f42f6/ty-0.0.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c181f42aa19b0ed7f1b0c2d559980b1f1d77cc09419f51c8321c7ddf67758853", size = 9542337, upload-time = "2026-01-14T22:30:05.687Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9c/f576e360441de7a8201daa6dc4ebc362853bc5305e059cceeb02ebdd9a48/ty-0.0.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1f829e1eecd39c3e1b032149db7ae6a3284f72fc36b42436e65243a9ed1173db", size = 8909582, upload-time = "2026-01-14T22:30:46.089Z" }, - { url = "https://files.pythonhosted.org/packages/d6/13/0898e494032a5d8af3060733d12929e3e7716db6c75eac63fa125730a3e7/ty-0.0.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45162e7826e1789cf3374627883cdeb0d56b82473a0771923e4572928e90be3", size = 9384932, upload-time = "2026-01-14T22:30:13.769Z" }, - { url = "https://files.pythonhosted.org/packages/e4/1a/b35b6c697008a11d4cedfd34d9672db2f0a0621ec80ece109e13fca4dfef/ty-0.0.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d11fec40b269bec01e751b2337d1c7ffa959a2c2090a950d7e21c2792442cccd", size = 9453140, upload-time = "2026-01-14T22:30:11.131Z" }, - { url = "https://files.pythonhosted.org/packages/dd/1e/71c9edbc79a3c88a0711324458f29c7dbf6c23452c6e760dc25725483064/ty-0.0.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09d99e37e761a4d2651ad9d5a610d11235fbcbf35dc6d4bc04abf54e7cf894f1", size = 9960680, upload-time = "2026-01-14T22:30:33.621Z" }, - { url = "https://files.pythonhosted.org/packages/0e/75/39375129f62dd22f6ad5a99cd2a42fd27d8b91b235ce2db86875cdad397d/ty-0.0.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d9ca0cdb17bd37397da7b16a7cd23423fc65c3f9691e453ad46c723d121225a1", size = 10904518, upload-time = "2026-01-14T22:30:08.464Z" }, - { url = "https://files.pythonhosted.org/packages/32/5e/26c6d88fafa11a9d31ca9f4d12989f57782ec61e7291d4802d685b5be118/ty-0.0.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcf2757b905e7eddb7e456140066335b18eb68b634a9f72d6f54a427ab042c64", size = 10525001, upload-time = "2026-01-14T22:30:16.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a5/2f0b91894af13187110f9ad7ee926d86e4e6efa755c9c88a820ed7f84c85/ty-0.0.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00cf34c1ebe1147efeda3021a1064baa222c18cdac114b7b050bbe42deb4ca80", size = 10307103, upload-time = "2026-01-14T22:30:41.221Z" }, - { url = "https://files.pythonhosted.org/packages/4b/77/13d0410827e4bc713ebb7fdaf6b3590b37dcb1b82e0a81717b65548f2442/ty-0.0.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb3a655bd869352e9a22938d707631ac9fbca1016242b1f6d132d78f347c851", size = 10072737, upload-time = "2026-01-14T22:30:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/e1/dd/fc36d8bac806c74cf04b4ca735bca14d19967ca84d88f31e121767880df1/ty-0.0.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4658e282c7cb82be304052f8f64f9925f23c3c4f90eeeb32663c74c4b095d7ba", size = 9368726, upload-time = "2026-01-14T22:30:18.683Z" }, - { url = "https://files.pythonhosted.org/packages/54/70/9e8e461647550f83e2fe54bc632ccbdc17a4909644783cdbdd17f7296059/ty-0.0.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c167d838eaaa06e03bb66a517f75296b643d950fbd93c1d1686a187e5a8dbd1f", size = 9454704, upload-time = "2026-01-14T22:30:22.759Z" }, - { url = "https://files.pythonhosted.org/packages/04/9b/6292cf7c14a0efeca0539cf7d78f453beff0475cb039fbea0eb5d07d343d/ty-0.0.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2956e0c9ab7023533b461d8a0e6b2ea7b78e01a8dde0688e8234d0fce10c4c1c", size = 9649829, upload-time = "2026-01-14T22:30:31.234Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/472a5d2013371e4870886cff791c94abdf0b92d43d305dd0f8e06b6ff719/ty-0.0.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c6a3fd7479580009f21002f3828320621d8a82d53b7ba36993234e3ccad58c8", size = 10162814, upload-time = "2026-01-14T22:30:36.174Z" }, - { url = "https://files.pythonhosted.org/packages/31/e9/2ecbe56826759845a7c21d80aa28187865ea62bc9757b056f6cbc06f78ed/ty-0.0.12-py3-none-win32.whl", hash = "sha256:a91c24fd75c0f1796d8ede9083e2c0ec96f106dbda73a09fe3135e075d31f742", size = 9140115, upload-time = "2026-01-14T22:30:38.903Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6d/d9531eff35a5c0ec9dbc10231fac21f9dd6504814048e81d6ce1c84dc566/ty-0.0.12-py3-none-win_amd64.whl", hash = "sha256:df151894be55c22d47068b0f3b484aff9e638761e2267e115d515fcc9c5b4a4b", size = 9884532, upload-time = "2026-01-14T22:30:25.112Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f3/20b49e75967023b123a221134548ad7000f9429f13fdcdda115b4c26305f/ty-0.0.12-py3-none-win_arm64.whl", hash = "sha256:cea99d334b05629de937ce52f43278acf155d3a316ad6a35356635f886be20ea", size = 9313974, upload-time = "2026-01-14T22:30:27.44Z" }, +version = "0.0.33" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" }, + { url = "https://files.pythonhosted.org/packages/00/ca/ba1eed819895bd239fba8ee35dfcd5fcb266c203b0914a17a59579096bb5/ty-0.0.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4a2b5ba078f90de342f56b5f7979bb77c9b9b1d8625a041352ffc6ee93c4073", size = 10777272, upload-time = "2026-04-28T10:45:32.905Z" }, + { url = "https://files.pythonhosted.org/packages/25/a8/c3131d37b44b3fea1d6654a1c929a0cd0873822f77a90482b8ec28f6fbbd/ty-0.0.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84ff5707825e9af9668d2bcf66975f93e520a63b524ab494e3a8265735be2563", size = 10201078, upload-time = "2026-04-28T10:45:23.374Z" }, + { url = "https://files.pythonhosted.org/packages/7b/db/d8e37ff0045810cc65e1ff36aa0da0a2253c05659787ac987df8a16c7897/ty-0.0.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e375285736f57886868e7af0b11c7b0ec5b6543fa15e7ad2a714fed9f077d4e0", size = 10732347, upload-time = "2026-04-28T10:45:21.444Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1a/20e83a412506a918e4684fc67b567cf7cc13b105470b3428cb23c3d5aa13/ty-0.0.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5680f6350c3b4e46b8bff6d7bb132366ea239463d6cad4892725d06046e65464", size = 10808238, upload-time = "2026-04-28T10:45:38.565Z" }, + { url = "https://files.pythonhosted.org/packages/5d/4b/d0a39f4464dc6cb4cc2c159473ce216bd1846bfb684c0323a3cb36dce5c6/ty-0.0.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5535538bad8d0f7e62bcdff02197cdb30e41451d80b35d27e17d128f2e1dc5d", size = 11288348, upload-time = "2026-04-28T10:45:08.419Z" }, + { url = "https://files.pythonhosted.org/packages/35/7e/f1745e0f9583363d7a83d9a4990fc244f76ecc30840ddad83dc16a33c52d/ty-0.0.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da196c42bbbc069e1e21e3e52107c061aa9660352dae57a41930690b56e2c02d", size = 11789907, upload-time = "2026-04-28T10:45:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a5/71/25f39f46a12d662859d45bc648555d0661044eb43db6b5648c9947487da9/ty-0.0.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9281672921ef6d4460e03146b5e6c18cb1a3e3a3b8a1a88f6f33226d05a469b7", size = 11500774, upload-time = "2026-04-28T10:45:48.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/ec/136959ecbb7c71cb90537f5aea441c73f4ab24612868a6ecdc9d7444d32d/ty-0.0.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1b8f303f82da64e878108e764be3ecbcd7c9903ac0a7f7031614ed00b97ab", size = 11360314, upload-time = "2026-04-28T10:45:05.402Z" }, + { url = "https://files.pythonhosted.org/packages/cf/95/32809575c222f00beed498cb728e9290a0f5009f930025381bb7253b2206/ty-0.0.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:efe3af412c9ff67bce5fa37d0a2b0d8555c24072b145a5bac6c79637f1c83abe", size = 10707785, upload-time = "2026-04-28T10:45:10.836Z" }, + { url = "https://files.pythonhosted.org/packages/13/89/c8e9531f7aa4a093359e15fa32c8e1277fbbe90d16894d7c6032d29f4b34/ty-0.0.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aeec29c91ea768601747da546c3efc20b72c2fb1bd52bcc786a5c6eeff51d27b", size = 10834987, upload-time = "2026-04-28T10:45:40.738Z" }, + { url = "https://files.pythonhosted.org/packages/31/16/9835fbcf5338af1a1917bd28fdb8a7193c210b83f243aa286fa9f79cb3ad/ty-0.0.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a535977c52bbb5f7e96b8b70a6ad375ad077f4a9ff2492508ea3816a2b403819", size = 10968968, upload-time = "2026-04-28T10:45:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/64c76aabc1bc70c7f24b686cd93c3407f8ea430905e395f59bf9603ef571/ty-0.0.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d732facf39fcb221ba279d469c5040d37883e964f123b1563888efd34818180", size = 11458077, upload-time = "2026-04-28T10:45:45.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/84/fae27b0c4718776a298690d31ca4cc1995f2e3e1c63a7b59e84c41498e9a/ty-0.0.33-py3-none-win32.whl", hash = "sha256:d90960b574428dc252f85e8598ec5fcb7f619794196b2fc95a90da075ed4681c", size = 10345364, upload-time = "2026-04-28T10:45:16.836Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a0/a2938b23ae3e1a09a2d7c189e2ac5f7113676bae4e0e23948b568e18e5f8/ty-0.0.33-py3-none-win_amd64.whl", hash = "sha256:c1c3aec62c44de610c6e95f0a4e97ac3dbc07934bfdbf1fd90d758c9ff72f48e", size = 11342470, upload-time = "2026-04-28T10:45:26.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/7fb948aace38d2f6329261bb33c035a8484549c74f1db28649c7a4c6fed9/ty-0.0.33-py3-none-win_arm64.whl", hash = "sha256:0d44f99ba1b441e55e2aa301b2ac0a21112784931b46a5f66f4ea9efe5620d97", size = 10742673, upload-time = "2026-04-28T10:45:35.555Z" }, ] [[package]] @@ -1618,15 +1984,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, ] [package.optional-dependencies] @@ -1656,16 +2022,17 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.36.1" +version = "21.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, + { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, + { url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" }, ] [[package]] @@ -1692,6 +2059,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, ] +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + [[package]] name = "websockets" version = "16.0" @@ -1734,41 +2110,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/6b/3fcfd8589d0e319f5fb56f105acbe791198fd36bb54469a91fbe49d828c4/xacro-2.1.1-py3-none-any.whl", hash = "sha256:c3b330ebd984a3bce6d6482e0047eae5c5333fedd49b30b9b6df863a086b35f7", size = 27087, upload-time = "2025-08-28T18:21:19.165Z" }, ] +[[package]] +name = "xmlschema" +version = "4.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "elementpath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/c4/ef78a231be72349fd6677b989ff80e276ef62e28054c36c4fea3b4db9611/xmlschema-4.3.1.tar.gz", hash = "sha256:853effdfaf127849d4724368c17bd669e7f1486e15a0376404ad7954ec31a338", size = 646611, upload-time = "2026-01-17T23:01:04.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/7b/3471405875d0b5fac642e9a879b2c7db63642370799b2e9eea8297ffbad0/xmlschema-4.3.1-py3-none-any.whl", hash = "sha256:9560314d70ae87be0aecb8712cfebed636f867707ccf9758d4b0645d607f64b9", size = 469891, upload-time = "2026-01-17T23:01:00.39Z" }, +] + [[package]] name = "xmltodict" -version = "1.0.2" +version = "1.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/aa/917ceeed4dbb80d2f04dbd0c784b7ee7bba8ae5a54837ef0e5e062cd3cfb/xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649", size = 25725, upload-time = "2025-09-17T21:59:26.459Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/20/69a0e6058bc5ea74892d089d64dfc3a62ba78917ec5e2cfa70f7c92ba3a5/xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d", size = 13893, upload-time = "2025-09-17T21:59:24.859Z" }, + { url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" }, ] [[package]] name = "yarl" -version = "1.22.0" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] From 0ca29e9c609cee81ce1f2967f4515de4981368ec Mon Sep 17 00:00:00 2001 From: Rosalie Date: Thu, 9 Apr 2026 12:36:23 +0200 Subject: [PATCH 113/119] Fix bug that appeared for collision monitor, and change /cmd_vel to /cmd_vel_final to cause no confusion Signed-off-by: Rosalie Signed-off-by: Peter Geurts --- .../husarion/urdf/original/gazebo.urdf.xacro | 2 +- .../src/alliander_tests/tests/test_collision_monitor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro b/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro index e451f3167..546d9f4af 100644 --- a/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro +++ b/alliander_core/src/alliander_description/husarion/urdf/original/gazebo.urdf.xacro @@ -141,4 +141,4 @@ - \ No newline at end of file + diff --git a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py index 86b25b439..8320cce02 100644 --- a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py +++ b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py @@ -48,7 +48,7 @@ def callback_function_cmd_vel(msg: TwistStamped) -> None: test_node.create_subscription( msg_type=TwistStamped, - topic=f"/{self.platforms['vehicle'].namespace}/cmd_vel", + topic=f"/{self.platforms['vehicle'].namespace}/cmd_vel_final", callback=callback_function_cmd_vel, qos_profile=10, ) From df77e46b4ad661142f3e069648be8f3cd25b2a9f Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 30 Apr 2026 16:47:01 +0200 Subject: [PATCH 114/119] fixed uv.lock; added documentation Signed-off-by: Peter Geurts --- docs/content/platforms.md | 11 +++++++++++ docs/img/xsens/imu.png | 3 +++ docs/img/xsens/xsens.png.license | 3 +++ uv.lock | 33 +++++++++++++++++++++++++++++++- 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 docs/img/xsens/imu.png create mode 100644 docs/img/xsens/xsens.png.license diff --git a/docs/content/platforms.md b/docs/content/platforms.md index 0c17a90c8..ecaada4c4 100644 --- a/docs/content/platforms.md +++ b/docs/content/platforms.md @@ -176,6 +176,17 @@ When using the Velodyne lidar, make sure that the IP-address of the host device | ![Velodyne settings](../img/velodyne/velodyne_settings.png) | ![Teltonika settings](../img/teltonika/teltonika_settings.png) | |-------------------------------------------------------------|----------------------------------------------------------------| +## Xsens IMU +![Xsens](../img/xsens/imu.png) + +### Simulation Xsens + +An Xsens IMU can be launched by creating a configuration with an *IMU* of type *xsens*. + +### Hardware Xsens + +When using the Xsens IMU, make sure that the IMU shows up on your device (use *lsusb* to check) and that the Docker container runs with *privileged: true* (standard in our repo). + ## Teltonika GPS ![Teltonika](../img/teltonika/nmea.png) diff --git a/docs/img/xsens/imu.png b/docs/img/xsens/imu.png new file mode 100644 index 000000000..e1a60de77 --- /dev/null +++ b/docs/img/xsens/imu.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f69c668566e3754af1145f20770e1240f968c6eb79393002c40c7ad166e57c4d +size 160120 diff --git a/docs/img/xsens/xsens.png.license b/docs/img/xsens/xsens.png.license new file mode 100644 index 000000000..14a6ee96d --- /dev/null +++ b/docs/img/xsens/xsens.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Alliander N. V. + +SPDX-License-Identifier: Apache-2.0 diff --git a/uv.lock b/uv.lock index e34784da6..b0fa24e2d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = "==3.12.*" [[package]] @@ -452,6 +452,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/fe/f61e7129e9e689d9e40bbf8a36fb90f04eceb477f4617c02c6a18463e81f/configparser-7.2.0-py3-none-any.whl", hash = "sha256:fee5e1f3db4156dcd0ed95bc4edfa3580475537711f67a819c966b389d09ce62", size = 17232, upload-time = "2025-03-08T16:04:07.743Z" }, ] +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, +] + [[package]] name = "cryptography" version = "47.0.0" @@ -491,6 +513,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "distlib" version = "0.4.0" From 60d7f5dba6704cda819f12b4cc132b250265ed68 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 30 Apr 2026 16:49:17 +0200 Subject: [PATCH 115/119] fix Signed-off-by: Peter Geurts --- uv.lock | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/uv.lock b/uv.lock index b0fa24e2d..33dbdf545 100644 --- a/uv.lock +++ b/uv.lock @@ -939,6 +939,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/5a/4fed77781061647d3be98e2f235ef1869289dd839ca0451a8d50a30fcd5c/mashumaro-3.20-py3-none-any.whl", hash = "sha256:648bc326f64c55447988eab67d6bfe3b7958c0961c83590709b1f950f88f4a3c", size = 94942, upload-time = "2026-02-09T21:53:53.343Z" }, ] +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -1151,6 +1177,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" From b5d7907bcf276b333be82b752fd658fb78a88f44 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Thu, 30 Apr 2026 17:00:43 +0200 Subject: [PATCH 116/119] linting Signed-off-by: Peter Geurts --- .../alliander_xsens/scripts/estimate_magnetometer_bias.py | 8 ++++++-- docs/img/xsens/{imu.png => xsens.png} | 0 2 files changed, 6 insertions(+), 2 deletions(-) rename docs/img/xsens/{imu.png => xsens.png} (100%) diff --git a/alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py b/alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py index 5765c0bdb..27f76cfa0 100755 --- a/alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py +++ b/alliander_xsens/src/alliander_xsens/scripts/estimate_magnetometer_bias.py @@ -70,7 +70,11 @@ def __init__(self, csv_file: str = "") -> None: self.parse_csv(csv_file) def mag_callback(self, msg: MagneticField) -> None: - """Append an incoming magnetometer sample to the internal buffers.""" + """Append an incoming magnetometer sample to the internal buffers. + + Args: + msg (MagneticField): Magnetic field message from IMU. + """ self.get_logger().info("Received magnetic field message.", once=True) self.num_mag_received += 1 self.stamp_mag_received = time.time() @@ -131,7 +135,7 @@ def parse_csv(self, csv_file: str) -> None: a single header row that is skipped during loading. Args: - csv_file: Path to the comma-separated data file. + csv_file (str): Path to the comma-separated data file. """ import numpy as np # noqa: PLC0415 diff --git a/docs/img/xsens/imu.png b/docs/img/xsens/xsens.png similarity index 100% rename from docs/img/xsens/imu.png rename to docs/img/xsens/xsens.png From d9307395c961dd8d78d60ef31be8b40258073d6d Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Fri, 1 May 2026 08:49:25 +0200 Subject: [PATCH 117/119] fixed tests locally Signed-off-by: Peter Geurts --- alliander_nav2/src/alliander_nav2/launch/nav2.launch.py | 3 +++ .../src/alliander_tests/tests/test_collision_monitor.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py index b73767c3a..efd857c26 100644 --- a/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py +++ b/alliander_nav2/src/alliander_nav2/launch/nav2.launch.py @@ -42,6 +42,9 @@ def launch_setup(context: LaunchContext) -> list: # noqa: PLR0912, PLR0915 namespace_gps = child.namespace if child.platform_type == "IMU" and not namespace_imu: namespace_imu = child.namespace + # if no external IMU is found, use the vehicle's internal IMU + if not namespace_imu: + namespace_imu = namespace_vehicle # Define configuration: lifecycle_nodes_names = [] diff --git a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py index 8320cce02..86b25b439 100644 --- a/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py +++ b/alliander_tests/src/alliander_tests/tests/test_collision_monitor.py @@ -48,7 +48,7 @@ def callback_function_cmd_vel(msg: TwistStamped) -> None: test_node.create_subscription( msg_type=TwistStamped, - topic=f"/{self.platforms['vehicle'].namespace}/cmd_vel_final", + topic=f"/{self.platforms['vehicle'].namespace}/cmd_vel", callback=callback_function_cmd_vel, qos_profile=10, ) From 26002d64b342e2b16c2e85586c89e1405dd0523d Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Mon, 4 May 2026 14:24:39 +0200 Subject: [PATCH 118/119] updates based on PR Signed-off-by: Peter Geurts --- .../alliander_visualization/tool_manager.py | 9 --------- alliander_xsens/alliander_xsens.Dockerfile | 2 +- .../src/alliander_xsens/config/xsens_mti_node.yaml | 2 ++ pyproject.toml | 1 - uv.lock | 2 -- 5 files changed, 3 insertions(+), 13 deletions(-) diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py index 837acbd31..f6e180c67 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py @@ -183,12 +183,3 @@ def add_gps(platform: GPS) -> None: platform (GPS): The GPS platform configuration. """ Rviz.add_satellite(f"/{platform.namespace}/gps/fix") - - @staticmethod - def add_imu(_platform: IMU) -> None: - """Add IMU configurtions to Rviz and Vizanti. Currently not implemented. - - Args: - _platform (IMU): The IMU platform configuration. - """ - pass diff --git a/alliander_xsens/alliander_xsens.Dockerfile b/alliander_xsens/alliander_xsens.Dockerfile index 4d1681698..795f299db 100644 --- a/alliander_xsens/alliander_xsens.Dockerfile +++ b/alliander_xsens/alliander_xsens.Dockerfile @@ -33,7 +33,7 @@ RUN /$WORKDIR/colcon_build.sh # Install python dependencies: WORKDIR $WORKDIR COPY pyproject.toml /$WORKDIR/pyproject.toml -RUN uv sync \ +RUN uv sync --group alliander-xsens \ && echo "export PYTHONPATH=\"$(dirname $(dirname $(uv python find)))/lib/python3.12/site-packages:\$PYTHONPATH\"" >> /root/.bashrc \ && echo "export PATH=\"$(dirname $(dirname $(uv python find)))/bin:\$PATH\"" >> /root/.bashrc diff --git a/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml index 2b1c121a9..d56583c0b 100644 --- a/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml +++ b/alliander_xsens/src/alliander_xsens/config/xsens_mti_node.yaml @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: Alliander N. V. # # SPDX-License-Identifier: Apache-2.0 +# +# Based on: https://github.com/xsenssupport/Xsens_MTi_ROS_Driver_and_Ntrip_Client/blob/main/src/xsens_ros_mti_driver/param/xsens_mti_node.yaml /**: ros__parameters: # Device Scanning Configuration diff --git a/pyproject.toml b/pyproject.toml index 3bb6023e4..dea492a67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ version = "0.1.0" requires-python = "==3.12.*" dependencies = [ "mashumaro>=3.17", - "pyserial>=3.5", "pyyaml>=6.0.3", "termcolor>=3.3.0", "xacro>=2.1.1", diff --git a/uv.lock b/uv.lock index 33dbdf545..b3665f4e2 100644 --- a/uv.lock +++ b/uv.lock @@ -82,7 +82,6 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "mashumaro" }, - { name = "pyserial" }, { name = "pyyaml" }, { name = "termcolor" }, { name = "xacro" }, @@ -152,7 +151,6 @@ testing = [ [package.metadata] requires-dist = [ { name = "mashumaro", specifier = ">=3.17" }, - { name = "pyserial", specifier = ">=3.5" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "termcolor", specifier = ">=3.3.0" }, { name = "xacro", specifier = ">=2.1.1" }, From 7bcf3a398700c4e4bd34b8ffe12273273dfe8f33 Mon Sep 17 00:00:00 2001 From: Peter Geurts Date: Mon, 4 May 2026 14:43:13 +0200 Subject: [PATCH 119/119] linting Signed-off-by: Peter Geurts --- .../alliander_visualization/tool_manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py index f6e180c67..a7c08a9f7 100644 --- a/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py +++ b/alliander_visualization/src/alliander_visualization/alliander_visualization/tool_manager.py @@ -5,7 +5,6 @@ from alliander_utilities.config_objects import ( GPS, - IMU, Arm, Camera, Lidar,