Robot stack for SpotMicro/Spot running ROS 2 Kilted on k3s (Raspberry Pi), deployed via Rancher Fleet (GitOps).
This repository contains deployment manifests, runtime services, and operations notes for the Spot robot stack.
This repository now includes a microservice architecture implementation for controlling an LCD1602 display on the Spot robot using C++ with ROS2.
The LCD service follows a modular ROS2 architecture with the following components:
lcd_core: Low-level LCD hardware interfacelcd_display_service: High-level display managementtemp_monitor: Temperature monitoring serviceuptime_monitor: System uptime monitoring serviceconfig: Configuration management
- Written in C++ with ROS2 integration
- Configurable via environment variables
- LCD redraw interval is configurable via
LCD_REFRESH_INTERVAL(seconds, default:10) - Dockerized with multistage build for minimal footprint
- Kubernetes-ready deployment manifests
- GPIO control for Raspberry Pi using libgpiod
- Modular architecture with separate ROS2 nodes
- DDS (LCD service): supports CycloneDDS (
rmw_cyclonedds_cpp) for reliable inter-container topic delivery on the robot
The system now supports multiple deployment architectures:
- Single executable with all modules combined
- Compatible with previous implementations
- Uses command-line flags to specify modules:
-m lcd,temp,uptime
- Separate ROS2 nodes for each module:
spot_lcd_cpp_lcd_node: Handles LCD display and subscribes to temperature/uptime topicsspot_lcd_cpp_temp_node: Monitors and publishes temperature dataspot_lcd_cpp_uptime_node: Monitors and publishes uptime data
- Each node can run in separate containers from the same image
- Nodes communicate via ROS2 topics
docker build -f docker/lcd/Dockerfile.cpp -t spot-lcd-cpp .
docker run -d --device=/dev/gpiochip0:/dev/gpiochip0 -v /proc:/proc spot-lcd-cpp:latest all# Build the image
docker build -f docker/lcd/Dockerfile.cpp -t spot-lcd-cpp .
# Run individual nodes
docker run -d --name lcd-node spot-lcd-cpp:latest lcd
docker run -d --name temp-node spot-lcd-cpp:latest temperature
docker run -d --name uptime-node spot-lcd-cpp:latest uptimeThe entrypoint script supports the following commands:
lcd- Run only the LCD display nodetemperatureortemp- Run only the temperature monitoring nodeuptime- Run only the uptime monitoring nodeallorcombined- Run the legacy combined node
The system is configured for Fleet-based deployment using the files in the root directory:
fleet.yaml- Main Fleet configurationkustomization.yaml- Kustomize configurationlcd.yaml- Deployment definitions for LCD services (contains DaemonSets for all three nodes)deployment.yaml- Main robot deployment
The lcd.yaml file defines three separate DaemonSets that can run on nodes with the appropriate labels:
spot-lcd-display- Handles LCD display functionalityspot-temp-monitor- Monitors system temperaturespot-uptime-monitor- Monitors system uptime
To deploy with Fleet, ensure your nodes have the required labels:
gorizond.io/robot=truegorizond.io/spot-lcd=true
The deployment will automatically use the image from GitHub Container Registry: ghcr.io/gorizond/spot-lcd-cpp:latest
The LCD service is fully integrated with GitHub Actions for automated building and publishing:
- Docker images are automatically built and published to GitHub Container Registry
- Workflow triggers on changes to LCD-related files
- Images tagged with branch names, semantic versions, and git SHAs.
spot-hcsr04 is deployed via hcsr04.yaml as a dedicated DaemonSet:
- image:
ghcr.io/gorizond/spot-hcsr04-cpp:latest - GPIO access via
/dev/gpiochip0 - default sensors mapping (from
SENSORS):front_left:5:12,front_right:6:13 - DDS runtime:
RMW_IMPLEMENTATION=rmw_cyclonedds_cpp(kept consistent with LCD stack)
Published topics:
/spot/sensor/ultrasonic/front_left/range/spot/sensor/ultrasonic/front_right/range
The node performs staggered measurements and emits a status line every minute per sensor with:
- TRIG/ECHO pins
- distance in meters and centimeters
- pulse width (
pulse_us) - status (
ok,rise_timeout,fall_timeout,echo_read_error) - measurement/timeouts counters (+delta per minute)
Example logs:
kubectl -n spot-system logs ds/spot-hcsr04 --tail=100Quick troubleshooting (field checklist):
- Verify there is no close obstacle in front of the sensor (bracket/cable/frame at 2–10 cm).
- If one channel is stuck around ~3–6 cm with frequent
rise_timeout, swap physical HC-SR04 modules between sides:- issue moves with module → faulty sensor module;
- issue stays on channel → wiring/pin/power path issue.
- Ensure ECHO line uses a proper 5V→3.3V divider/level shifting.
- Check 5V and GND quality at the sensor under load (brownouts/noisy GND cause unstable echoes).
- If left/right got physically rewired, update
SENSORSmapping accordingly.
- Symptom:
front_leftwas stuck around ~3–6 cm and frequently loggedrise_timeout. - Verification: ECHO path used a proper voltage divider; wiring was rechecked/swapped on sensor side.
- Root cause: faulty HC-SR04 module on the left channel.
- Resolution: replacing the module restored stable measurements.
- Related hardening: switched
spot-hcsr04tormw_cyclonedds_cppand added minute-level status logging for easier diagnostics.
deployment.yaml deploys two DaemonSets (one pod per robot node):
ros2-smoke(core):node-label-config: reads Kubernetes Node labels of its own node (RBAC-enabled), converts them into a PCA9685 servo mapping, and publishes it to/spot/config/servo_map(reacts to label changes via watch; no pod restart).servo-driver: subscribes to/spot/config/servo_map, drives PCA9685 over I2C (/dev/i2c-1), accepts JSON commands on/spot/cmd/servo, and publishes status to/spot/state/servo(runs privileged for I2C access).cmd-mux: routes commands to/spot/cmd/servofrom manual (/spot/cmd/servo_manual) or auto (/spot/cmd/servo_auto) based on/spot/ctrl/mode.
spot-champ(optional):champ-controller: runs CHAMP gait controller (joint_states,/cmd_vel, etc).champ-bridge: bridges CHAMPjoint_statesto/spot/cmd/servo_auto(applied only when mux is inauto).
Safety defaults:
- starts disarmed (
START_ARMED=0) - clamps each joint to
min/center/maxfrom node labels - hard safety clamp via
SAFE_MIN_US/SAFE_MAX_US(defaults:500..2500) - slew-rate limiting (
MAX_SLEW_US_PER_S)
Pods are scheduled only on nodes matching:
kubernetes.io/arch=arm64gorizond.io/robot=true
- RPi4 power: XL4015 fed directly from 2S (thick wires) to avoid voltage sag/heat.
- Servo power (
PCA9685 V+/ servos): separate6VUBEC; tie grounds at a star point. - Brownouts show up as boot-loops; measure 5V at the GPIO 5V/GND pins under load.
Create a Fleet GitRepo in your Fleet workspace (e.g. workspace-negashev) pointing to this repository and target your robot cluster.
spec:
repo: https://github.com/gorizond/spot
branch: main
targets:
- clusterName: spotsPer-robot mapping is stored on the Kubernetes Node object as labels.
Label prefix: gorizond.io/spot-pca9685-
...i2c-bus(default:1)...address(default:0x40)- Per channel
0..15:...chN-joint(unset/empty => unused)...chN-us(min/center/max, default:1000-1500-2000)...chN-invert(0/1, default:0)
...chN-us value must be a valid Kubernetes label value (no commas). Use e.g. 1450-1500-1550.
(Backward-compatible) ...chN-min-us, ...chN-center-us, ...chN-max-us are still supported if ...chN-us is not set.
For the current SpotMicro wiring, CH6–CH9 are empty (leave ch6-joint..ch9-joint unset).
-
Put the robot in a safe position (lifted / legs can move freely).
-
Exec into the DaemonSet pod and use the
servo-drivercontainer:
kubectl -n spot-system exec -it ds/ros2-smoke -c servo-driver -- bashIf you need a one-liner (non-interactive), use:
kubectl -n spot-system exec ds/ros2-smoke -c servo-driver -- bash -lc 'source /opt/ros/kilted/setup.bash && ros2 topic list'- Source ROS 2 environment (required for
rclpy/ros2commands):
source /opt/ros/kilted/setup.bash- Switch to manual mode (so CHAMP does not overwrite your commands).
Default mux mode is
auto, so manual control requires this:
ros2 topic pub /spot/ctrl/mode std_msgs/msg/String "data: manual" -1- (Optional) Reset targets to home (0.0):
python3 /opt/spot/spot_cli.py --repeat 1 home- Arm (does not move anything until you
seta joint):
python3 /opt/spot/spot_cli.py --repeat 1 arm- Enable + move one joint with small steps (calibration-friendly):
python3 /opt/spot/spot_cli.py --repeat 1 set-us rf_hip=1500
python3 /opt/spot/spot_cli.py --repeat 1 set-us rf_hip=1510
python3 /opt/spot/spot_cli.py --repeat 1 set-us rf_hip=1500- Disarm when done (note: may drop torque if
DISARM_FULL_OFF=1):
python3 /opt/spot/spot_cli.py disarmIf something goes wrong:
python3 /opt/spot/spot_cli.py estopUse limits from /spot/config/servo_map or node labels (gorizond.io/spot-pca9685-ch*-us).
On spot-5, a full front-right bend was visible with:
python3 /opt/spot/spot_cli.py --repeat 1 set-us rf_hip=500 rf_upper=2500 rf_lower=2450Return to a neutral-ish pose:
python3 /opt/spot/spot_cli.py --repeat 1 set-us rf_hip=500 rf_upper=1000 rf_lower=1500After calibration, a conservative stand pose is:
python3 /opt/spot/spot_cli.py --repeat 1 standDefaults (override via flags or env STAND_HIP/STAND_UPPER/STAND_LOWER/STAND_HIP_REAR_OFFSET/STAND_REAR_UPPER_OFFSET/STAND_REAR_LOWER_OFFSET):
hip=0.02upper=0.05lower=0.05rear_hip_offset=0.0(applied torh/lhonly)rear_upper_offset=0.0(applied torh/lhonly)rear_lower_offset=0.0(applied torh/lhonly)
Example: move rear hips back a bit (safer stance):
python3 /opt/spot/spot_cli.py --repeat 1 stand --rear-hip-offset -0.06One-leg micro-step (lift/return one leg by moving *_lower):
python3 /opt/spot/spot_cli.py --repeat 1 step lhRepeat micro-steps (in-place crawl):
python3 /opt/spot/spot_cli.py --repeat 1 walk --steps 3Tune lift amplitude and timing (start small):
python3 /opt/spot/spot_cli.py --repeat 1 walk --steps 1 --lift 0.05 --lift-hold 0.2 --down-hold 0.2(Experimental) add a small hip swing while the leg is lifted:
python3 /opt/spot/spot_cli.py --repeat 1 walk --steps 1 --hip-swing 0.03This uses CHAMP (model-based gait controller) and bridges its joint_states to the low-level servo-driver via the command mux.
spot-champ is gated by a node label so it doesn't override manual spot_cli commands.
To enable CHAMP on a robot node:
- Add label
gorizond.io/spot-champ=trueto the node (e.g.spot-1).
By default, spot-champ uses two images:
champ-controller:ghcr.io/gorizond/spot-champ:mainchamp-bridge:ghcr.io/gorizond/spot-champ-bridge-cpp:latest
Both are built and pushed by GitHub Actions in this repository.
If your package is private, configure an imagePullSecret for GHCR.
To build the controller image locally instead, edit deployment.yaml and run:
docker build -t spot-champ:local -f docker/champ/Dockerfile .Then, drive the gait by publishing /cmd_vel (start small):
kubectl -n spot-system exec -it ds/spot-champ -c champ-controller -- bash
source /opt/ros/kilted/setup.bash
[ -f /ws/install/setup.bash ] && source /ws/install/setup.bash
ros2 topic pub /spot/ctrl/mode std_msgs/msg/String "data: auto" -1
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.05}}' -r 2Notes:
champ-bridgepublishes servo targets only whenservo-driverisarmed=true.- Manual vs auto is controlled by
/spot/ctrl/mode(autoormanual). - Tune bridge scaling via env on
champ-bridge:CHAMP_GAIN,CHAMP_*_RANGE_RAD,STAND_*.
cmd-muxlistens:- manual:
/spot/cmd/servo_manual(default forspot_cli.py) - auto:
/spot/cmd/servo_auto(used bychamp-bridge) - output:
/spot/cmd/servo
- manual:
- Default mode is
auto. Switch tomanualbefore issuing direct commands. - Optional:
MANUAL_TIMEOUT_Scan auto-return toautoafter inactivity.
- DaemonSets are running in namespace
spot-system(ros2-smoke,spot-champ) - Logs:
node-label-configprints which channels are mappedservo-driverprints PCA9685 connect + updates
- ROS topics:
ros2 topic echo /spot/config/servo_map --once
ros2 topic echo /spot/state/servo --once
ros2 topic echo /spot/state/mux --once/spot/cmd/servo expects std_msgs/String JSON:
- arm/disarm:
{"cmd":"arm","value":true}/{"cmd":"arm","value":false}(arming gates output; joints move only after they are enabled viacmd=set) - estop:
{"cmd":"estop","value":true}/{"cmd":"estop","value":false} - home:
{"cmd":"home"} - set targets (also enables those joints):
- normalized:
{"cmd":"set","mode":"norm","targets":{"rf_hip":0.1}}(range-1..1) - microseconds:
{"cmd":"set","mode":"us","targets":{"rf_hip":1500}}
- normalized:
For manual control (via mux), publish to /spot/cmd/servo_manual (default in spot_cli.py).
For convenience inside the pod, use python3 /opt/spot/spot_cli.py ....