Comprehensive hardware + software spec for SAUL-TEE autonomous parking lot striping system. Covers paint dispensing hardware, control electronics (solenoid/relay/GPIO), RTK GPS positioning strategy, ROS2 node architecture, MQTT topics, and parking lot templates (ADA, standard, angled, symbols). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
20 KiB
Parking Lot Line Marking System — Hardware + Software Spec
Platform: SAUL-TEE Robot
Use case: Serkan — Autonomous parking lot striping
Author: mark (parking marking agent)
Date: 2026-04-07
Status: Draft v1.0
Table of Contents
- System Overview
- Paint Dispensing Hardware
- Control Electronics
- Positioning Strategy
- Software Architecture
- Parking Lot Templates
- Bill of Materials
- Safety
- Open Items
1. System Overview
SAUL-TEE autonomously traverses a parking lot surface while a rear-mounted paint dispensing system sprays crisp lines. The Jetson Orin drives RTK-corrected path following via differential VESC drive. The ESP32 IO board triggers a solenoid valve via relay on demand. Lines are generated from a layout file (SVG or JSON) and executed as a sequence of waypoints with paint-on/off events.
[Layout File] → [Layout Planner node]
↓
[Path Follower node] ← RTK/UWB pose
↓
[Paint Controller node] → GPIO → relay → solenoid → spray
2. Paint Dispensing Hardware
2.1 Spray Mechanism — Selected: Pressurized Airless Canister
Three options evaluated:
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| Pressurized aerosol canister | Cheap, no pump, self-contained, easy swap | ~500 mL, limited volume, cold-sensitive | Selected for v1 |
| Airless diaphragm pump + reservoir | Large capacity, refillable, consistent | Pump adds complexity, power draw ~3 A | v2 option |
| Gravity-fed dip tube | Simple, zero power | Requires height differential, drips on stop | Rejected |
v1 approach: Modified professional marking paint canister (e.g., Rustoleum Marking Chalk, inverted-spray capable) + normally-closed solenoid valve inline with canister outlet tube. Canister pressure (~50 PSI) propels paint through the solenoid to a fan nozzle aimed at the ground.
2.2 Nozzle Selection
| Line Width | Nozzle | Tip Orifice | Height from Ground | Notes |
|---|---|---|---|---|
| 4" standard parking line | Flat fan 40° | 0.017" | 8–10 cm | Covers standard stripe width at 50 PSI |
| 2" detail / curb line | Flat fan 20° | 0.013" | 6–8 cm | Narrower pattern |
| 6" fire lane | Flat fan 65° | 0.021" | 10–12 cm | Wide spray, lower pressure preferred |
Recommended nozzle: Graco RAC X 515 (flat fan, 10" fan width at 12" height) for 4" lines. Reversible tip for unclogging.
Critical: Nozzle tip height must be fixed relative to ground. Spring-loaded mount or rigid bracket set to calibrated height.
2.3 Solenoid Valve
- Type: Normally-closed (NC) 12 V DC solenoid valve
- Body: Brass or stainless, 1/4" NPT female ports
- Pressure rating: ≥ 100 PSI (canister runs at ~50 PSI)
- Response time: < 20 ms open/close (critical for line start/stop accuracy)
- Current draw: ~500 mA @ 12 V when open
- Recommended part: U.S. Solid USS-MSV00006 1/4" NC 12 V (≈ $18) or equivalent
Flyback diode: 1N4007 across solenoid coil terminals (cathode to +12 V).
2.4 Paint Reservoir / Canister Mount
v1 — Inverted aerosol canister:
- Standard 17 oz / 500 mL marking paint canister (inverted nozzle type)
- Custom bracket: 3 mm aluminum plate, two 3" U-bolt clamps for canister body
- Mount behind rear castor, centered on robot longitudinal axis
- Canister outlet → 1/4" OD PTFE tube → solenoid → nozzle
v2 — Refillable reservoir:
- 2 L HDPE pressure pot (Harbor Freight or similar, ~$35)
- External 12 V mini air compressor regulating to 30–60 PSI
- Float-level sensor (reed switch) for paint level monitoring
2.5 Mounting Bracket
Top rail (80/20 T-slot or robot frame)
│
├─── Canister bracket (rear, vertical, above castor)
│ U-bolts × 2 clamping canister body
│ QD fitting for tube swap
│
└─── Nozzle arm (extends aft ~200 mm, 50 mm above ground target height)
Adjustable height slot (bolt + lock nut)
Solenoid mounted inline, protected by splash shield
Key dimension: Nozzle center is 150 mm behind rear axle center. This offset is compensated in software — spray-on is triggered when the nozzle position (not robot center) reaches the line start point.
2.6 Power Requirements
| Component | Voltage | Current | Source |
|---|---|---|---|
| Solenoid valve | 12 V | 0.5 A peak (intermittent) | Robot 12 V rail |
| Relay module | 5 V logic, 12 V switched | < 50 mA control | 5 V rail via IO GPIO |
| RTK GPS module | 5 V | 100–200 mA | 5 V rail |
| (v2) Mini compressor | 12 V | 3–5 A | Direct from 36 V via 12 V DC-DC |
Total paint system power budget: < 1 A continuous (solenoid duty cycle ~20%).
3. Control Electronics
3.1 Signal Chain: Orin → Spray
Jetson Orin (ROS2 node)
│ publishes /paint/cmd (Bool)
│
[CAN or MQTT] ← preferred: direct GPIO on Orin Jetson 40-pin header
│
Orin GPIO pin (3.3 V logic)
│
Level shifter (3.3 V → 5 V) [optional, if relay module needs 5 V]
│
5 V relay module (optocoupler isolated, SPDT, 10 A contacts)
│ NO contact → +12 V
│ COM → solenoid +
│ solenoid − → GND
│
Solenoid valve (NC, 12 V)
│
Spray nozzle
3.2 Jetson Orin GPIO
The Jetson Orin Nano / AGX Orin 40-pin expansion header exposes GPIOs via Jetson.GPIO Python library or /sys/class/gpio.
| Signal | Orin Pin | GPIO # | Notes |
|---|---|---|---|
| SPRAY_EN | Pin 11 | GPIO09 (Tegra SOC) | Active high → relay ON → solenoid opens |
| ESTOP_IN | Pin 12 | GPIO08 | Monitor hardware kill switch state |
Use Jetson.GPIO in ROS2 node:
import Jetson.GPIO as GPIO
SPRAY_PIN = 11
GPIO.setmode(GPIO.BOARD)
GPIO.setup(SPRAY_PIN, GPIO.OUT, initial=GPIO.LOW)
GPIO.output(SPRAY_PIN, GPIO.HIGH) # spray on
GPIO.output(SPRAY_PIN, GPIO.LOW) # spray off
3.3 ESP32 IO Board — Alternative / Backup Path
If direct Orin GPIO is not preferred, route through the IO board.
Available IO pins (from SAUL-TEE-SYSTEM-REFERENCE.md):
| GPIO | Current Use | Available? |
|---|---|---|
| IO14 | Horn/buzzer | Yes (shared, low duty) |
| IO15 | Headlight | Yes (can multiplex) |
| IO16 | Fan (if ELRS not fitted) | Yes — recommended for spray relay |
Recommended: IO16 on ESP32 IO board → relay → solenoid.
Add new UART message type for spray control:
IO Board extension — TYPE 0x04: SPRAY_CMD
Payload: uint8 enable (0=off, 1=on), uint16 duration_ms (0=indefinite)
Orin sends via MQTT → ESP32 MQTT bridge → UART to IO board → GPIO16.
3.4 Safety Interlocks
| Condition | Action |
|---|---|
| E-STOP (RC CH6 > 1500 µs) | IO board immediately pulls IO16 LOW (spray off) before forwarding FAULT to BALANCE |
| Tilt > ±25° | BALANCE sends FAULT → Orin paint controller kills spray |
| Robot velocity = 0 (stationary) | paint_controller node holds spray off (prevents puddles) |
| RC loss > 100 ms | FAULT propagated → spray off |
| MQTT disconnected > 5 s | paint_controller enters SAFE mode, spray off |
IO board firmware addition: In the FAULT frame handler (TYPE 0x03), ensure IO16 is driven LOW before sending any FAULT frame upward. Zero-latency interlock at MCU level.
3.5 Flow Rate Sensor (Optional — v2)
- Type: YF-S201 Hall-effect flow sensor, 1/2" inline
- Signal: Pulse output, 7.5 pulses per mL
- Connected to: IO board I2C bus interrupt pin or free GPIO
- Purpose: Detect clog (rate drops to zero during spray command), estimate paint consumed
4. Positioning Strategy
4.1 Accuracy Requirements
| Parameter | Requirement | Rationale |
|---|---|---|
| Lateral position error | < ±2 cm | 4" (10 cm) line — 2 cm error leaves 3 cm margin |
| Heading error | < ±0.5° | Over 20 m line, 0.5° heading error = 17 cm drift — marginal but acceptable with frequent corrections |
| Longitudinal (along-line) error | < ±5 cm | Controls spray on/off timing |
| Update rate | ≥ 5 Hz | At 0.3 m/s robot speed, 5 Hz gives 6 cm per update |
Conclusion: Phone GPS (±5 m) alone is insufficient. RTK GPS (±2 cm) is required for production use.
4.2 RTK GPS Module
Recommended: u-blox ZED-F9P (standalone RTK receiver)
| Spec | Value |
|---|---|
| RTK accuracy (fixed) | 1 cm + 1 ppm horizontal |
| Heading (dual antenna) | 0.3° RMS |
| Update rate | 10 Hz (RTK), 20 Hz raw |
| Interface | UART (NMEA + UBX), USB |
| Power | 5 V, ~150 mA |
| Module board | SparkFun GPS-RTK2 (ZED-F9P) ~$250, or ArduSimple simpleRTK2B ~$200 |
NTRIP correction source: Use a local CORS network or set up a second ZED-F9P base station at a known point on the parking lot boundary. Base → Orin NTRIP client → RTK corrections to rover.
Antenna placement: Center of robot, clear sky view. Keep > 30 cm from metal frame edges.
4.3 Dual Antenna Heading (Moving Baseline RTK)
Use two ZED-F9P modules (or one ZED-F9P + ZED-F9H) with antennas at robot front and rear, separated by ≥ 50 cm. This gives direct heading from GPS — no magnetometer drift issues.
| Antenna separation | Heading accuracy |
|---|---|
| 50 cm | ~1.1° RMS |
| 100 cm | ~0.57° RMS |
| 150 cm | ~0.38° RMS |
Recommended: 100 cm baseline along robot longitudinal axis → heading accuracy 0.57° RMS.
4.4 Sensor Fusion Architecture
[ZED-F9P RTK rover] ── UART ──→ robot_localization EKF
[Phone GPS MQTT bridge] ──────→ (fallback, coarse)
[UWB tag] ────────────────────→ (near base station, ±2 cm augment)
[IMU - QMI8658 via CAN 0x400]──→ dead reckoning between GPS updates
↓
/saltybot/pose (geometry_msgs/PoseWithCovarianceStamped)
- robot_localization (ROS2 EKF node) fuses GPS + IMU
- RTK GPS is primary when fixed; covariance set high when float/no fix
- UWB active near parking lot entry/exit points (anchor infrastructure needed)
- Phone GPS used for coarse positioning only, filtered out when RTK fixed
4.5 Wheel Odometry
At 0.3 m/s nominal painting speed, the VESC RPM telemetry (CAN 0x401) provides ~1 cm odometry resolution. Combine with IMU heading for dead-reckoning between RTK updates.
5. Software Architecture
5.1 ROS2 Node Graph
/layout_planner
Reads: layout JSON file
Publishes: /paint/path (nav_msgs/Path), /paint/zones (PaintZone[])
/path_follower
Subscribes: /paint/path, /saltybot/pose
Publishes: /cmd_vel (geometry_msgs/Twist) → CAN 0x300 bridge
Algorithm: Pure Pursuit with adaptive lookahead (0.3 m at low speed)
/paint_controller
Subscribes: /saltybot/pose, /paint/zones
Publishes: /paint/spray_cmd (std_msgs/Bool) → GPIO
Logic: nozzle-offset compensation, edge start/stop, velocity interlock
/rtk_bridge
Reads: ZED-F9P UART (NMEA GGA, UBX-NAV-PVT)
Publishes: /gps/fix (sensor_msgs/NavSatFix), /gps/heading (Float64)
/paint_monitor (MQTT bridge)
Bridges: /paint/status → MQTT saltybot/mark/status
Bridges: MQTT saltybot/mark/cmd → /paint/cmd
5.2 MQTT Topics
| Topic | Direction | Payload | Description |
|---|---|---|---|
saltybot/mark/status |
Orin → broker | JSON | Current state: pose, spray_state, progress_pct, paint_remaining |
saltybot/mark/cmd |
broker → Orin | JSON | Commands: start, stop, pause, load_layout |
saltybot/mark/layout |
broker → Orin | JSON | Full layout upload |
saltybot/mark/fault |
Orin → broker | JSON | Fault events: e-stop, gps_lost, clog_detected |
saltybot/mark/spray |
Orin → ESP32 IO | {"en":1} |
Direct spray relay command (fallback path) |
5.3 Layout File Format (JSON)
{
"version": 1,
"origin": {"lat": 37.4219983, "lon": -122.084},
"lines": [
{
"id": "row_A_space_1_left",
"type": "stripe",
"width_mm": 100,
"start": {"x_m": 0.0, "y_m": 0.0},
"end": {"x_m": 5.5, "y_m": 0.0}
}
],
"symbols": [
{
"id": "hc_symbol_1",
"type": "handicap_iso",
"center": {"x_m": 12.0, "y_m": 2.3},
"heading_deg": 0
}
]
}
Coordinates are in a local ENU (East-North-Up) frame anchored at origin.
5.4 path_follower — Pure Pursuit
class PathFollower(Node):
LOOKAHEAD = 0.35 # m — tunable
MAX_SPEED = 0.3 # m/s during painting
NOZZLE_OFFSET = -0.15 # m aft of rear axle
def compute_cmd_vel(self, pose, path):
# Find lookahead point on path
# Compute curvature κ = 2*sin(α) / L
# angular_vel = κ * linear_vel
...
Speed: 0.3 m/s nominal (≈ 1 ft/s). At this speed, 100 m line ≈ 5.5 min.
5.5 paint_controller — Spray Logic
NOZZLE_OFFSET_M = -0.15 # nozzle is 15 cm behind robot center (rear)
def nozzle_pose(robot_pose):
# Project robot pose backward by offset along heading
...
def on_pose_update(self, pose):
nozzle = nozzle_pose(pose)
# Check if nozzle is within any active paint zone
in_zone = any(zone.contains(nozzle) for zone in self.active_zones)
# Velocity interlock: must be moving > 0.05 m/s
moving = self.current_speed > 0.05
spray = in_zone and moving and not self.fault
self.set_spray(spray)
5.6 Integration with Existing Nodes
| Existing node | Integration point |
|---|---|
ios_gps_bridge (Issue #681) |
Provides fallback /gps/fix when RTK not fixed |
| VESC CAN bridge | path_follower publishes to /cmd_vel → existing bridge forwards to CAN 0x300 |
saul_tee_driver |
Subscribes /cmd_vel — no changes needed |
| UWB tag node | robot_localization already fuses UWB range — paint system uses same /pose topic |
6. Parking Lot Templates
All templates follow MUTCD (Manual on Uniform Traffic Control Devices) and ADA standards.
6.1 Standard Dimensions
| Space Type | Width | Depth | Line Width | Notes |
|---|---|---|---|---|
| Standard | 9 ft (2.74 m) | 18 ft (5.49 m) | 4 in (102 mm) | Most common |
| Compact | 8 ft (2.44 m) | 16 ft (4.88 m) | 4 in | Must be labeled |
| ADA accessible | 13 ft (3.96 m) | 18 ft | 4 in | Includes 5' access aisle |
| Van accessible | 16 ft (4.88 m) | 18 ft | 4 in | 8' space + 8' aisle |
| Fire lane | varies | — | 6 in (152 mm) | Red or yellow, MUTCD |
6.2 Stall Angle Templates
| Angle | Module width | Drive aisle |
|---|---|---|
| 90° (perpendicular) | 9.0 ft stall + 9.0 ft module | 24 ft two-way |
| 60° angled | 10.4 ft module | 18 ft one-way |
| 45° angled | 12.7 ft module | 13 ft one-way |
| Parallel | 23 ft length | 12 ft one-way |
6.3 Symbol Templates
Handicap Symbol (ISA — International Symbol of Access)
Standard 60" × 60" (1.52 m × 1.52 m) wheelchair figure, blue paint. Consists of:
- Head circle: 6" diameter
- Body and wheel segments: 4–6" strokes
Robot approach: rasterize ISA symbol into a grid of parallel stripe passes at 2" nozzle width, ~30 passes per symbol.
Arrow Templates
| Type | Dimensions | Use |
|---|---|---|
| Straight arrow | 6" wide × 36" long shaft + 18" head | One-way lane |
| Curved arrow | 6" wide, 10 ft radius | Turn lane |
| Double-headed | 6" wide | Two-way reminder |
Fire Lane Markings
- "FIRE LANE — NO PARKING" text: 12" tall letters, 4" stroke
- Red curb paint: continuous stripe, 6" wide on curb face (robot with angle-mounted nozzle)
- MUTCD crosshatch: 6" lines at 45°, 18" spacing
6.4 Layout Design Tool (CLI)
A Python script tools/parking_layout.py will generate a layout JSON from simple parameters:
# Generate a 10-space 90° lot section
python tools/parking_layout.py \
--type 90deg \
--spaces 10 \
--rows 2 \
--origin 37.4219983,-122.084 \
--output lot_A.json
# Preview as SVG
python tools/parking_layout.py --input lot_A.json --svg lot_A.svg
7. Bill of Materials
7.1 Paint Dispensing System
| Item | Part / Source | Qty | Unit Cost | Total |
|---|---|---|---|---|
| Inverted marking paint canister (Rustoleum Professional) | Home Depot / Rustoleum | 4 | $8 | $32 |
| NC solenoid valve 12 V 1/4" NPT | U.S. Solid / Amazon | 1 | $18 | $18 |
| 5 V single-channel relay module | Amazon | 1 | $5 | $5 |
| Graco RAC X 515 reversible tip | Paint supply | 1 | $25 | $25 |
| 1/4" OD PTFE tubing (1 m) | Amazon | 1 | $8 | $8 |
| 1/4" NPT push-to-connect fittings | Amazon | 4 | $3 | $12 |
| 1N4007 flyback diode | electronics | 2 | $0.10 | $1 |
| 3 mm aluminum plate (bracket stock) | Metal supermarket | 1 | $15 | $15 |
| M5 hardware (bolts, nuts, standoffs) | — | lot | $5 | $5 |
| 80/20 T-slot bracket 10-series | 80/20 Inc. | 2 | $8 | $16 |
| PTFE thread tape | — | 1 | $2 | $2 |
| Splash shield (acrylic 3 mm) | — | 1 | $5 | $5 |
| Subtotal — paint system | $144 |
7.2 Positioning (RTK GPS)
| Item | Part / Source | Qty | Unit Cost | Total |
|---|---|---|---|---|
| u-blox ZED-F9P RTK board (rover) | SparkFun GPS-RTK2 | 1 | $250 | $250 |
| u-blox ZED-F9P RTK board (base) | SparkFun GPS-RTK2 | 1 | $250 | $250 |
| Survey GNSS antenna (L1/L2) | SparkFun / u-blox | 2 | $65 | $130 |
| SMA cable (3 m) | Amazon | 2 | $12 | $24 |
| USB-A to USB-C cable (Orin) | — | 1 | $8 | $8 |
| Subtotal — RTK GPS | $662 |
7.3 Software / Labor
All software is open-source / in-house. No licensing cost.
7.4 Total Estimated BOM
| Category | Cost |
|---|---|
| Paint dispensing system | $144 |
| RTK GPS system | $662 |
| Contingency (10%) | $81 |
| Total | $887 |
8. Safety
8.1 Paint Hazard
- Aerosol paint is flammable. Keep robot battery terminals isolated from paint canister.
- Ensure ventilation if operating in enclosed areas.
- Overspray: operate only in calm wind conditions (< 10 mph / 16 km/h).
8.2 Electrical Safety
- Relay module must be rated for 12 V / 1 A minimum (solenoid inrush).
- Flyback diode mandatory across solenoid coil — unclamped kickback can damage relay contacts and IO GPIO.
- Fuse solenoid 12 V line with a 2 A polyfuse.
8.3 Operational Safety
- Never enable autonomous movement unless lot is clear.
- Use RC ESTOP (CH6) as immediate abort — confirm IO board firmware pulls spray pin LOW in FAULT handler.
- Run at max 0.5 m/s with human observer for initial field trials.
- Paint-on while stationary is locked out in software (puddle prevention).
9. Open Items
| # | Item | Owner | Priority |
|---|---|---|---|
| 1 | Confirm Orin GPIO pin accessibility (40-pin header on Jetson Orin Nano vs AGX) | mark / hal | High |
| 2 | Source RTK CORS network credentials for target parking lot area | Serkan | High |
| 3 | Determine parking lot GPS coordinates / anchor point for origin | Serkan | High |
| 4 | Fabricate aluminum canister bracket + nozzle arm | Serkan / mech | Medium |
| 5 | Write IO board firmware extension (TYPE 0x04 SPRAY_CMD) | mark | Medium |
| 6 | Implement tools/parking_layout.py layout designer |
mark | Medium |
| 7 | Field test RTK fix acquisition time in target lot (tree/building multipath) | mark | Medium |
| 8 | Calibrate nozzle offset constant (15 cm assumed — measure actual) | mark | Low |
| 9 | Evaluate YF-S201 flow sensor for clog detection (v2) | mark | Low |
Spec written by mark — parking lot marking agent.
Questions/corrections → MQTT: saltybot/mark/status or open GitHub issue in saltylab-firmware.