Some hardware links on this page are affiliate links. As an Amazon Associate I earn from qualifying purchases. We may earn a commission if you buy through them, at no extra cost to you. See the affiliate disclosure.
The tennis ball on a string works until someone else parks, the ball gets bumped, or you buy a longer car. This build puts a LiDAR distance sensor on the front wall and a NeoPixel ring at eye level. As you pull in, the ring fades from blue to green to yellow to red, then blinks red when the bumper reaches your stop line. Nothing hangs from the ceiling, and you change the stop point from your phone instead of with a ladder.
Wi-Fi credentials stay in your ESPHome secrets file. Never paste SSIDs, PSKs, API keys, or OTA passwords into a published YAML.
What problem it solves
Pulling in too far hits the shelves or the wall. Stopping too short leaves the garage door coming down on the hatch. A hanging ball only marks one spot, you have to aim the windshield at it, and it has to be re-hung for every car. The ring gives you a countdown you can read from the driver’s seat, so you ease off early and stop on the same mark every time.
What you get working
- A color countdown. Blue when the car first comes into range, through green and yellow, to solid red just before the stop line. Past the stop line the ring blinks red four times a second.
- Auto-off. When the car has sat still for a minute, the ring goes dark, so it does not glow all night. It wakes up again as soon as anything moves in front of the sensor.
- Distances you can tune from Home Assistant. Wake distance, stop distance, and parked distance are number entities. Change them from a dashboard, no reflash needed, and they survive a reboot.
- A Car parked sensor for Home Assistant, plus a live Parking distance sensor in centimeters.
- Diagnostics: LiDAR signal strength, Wi-Fi signal, uptime, and a restart button.
Hardware notes
- Sensor. The TFmini-S is a single-point LiDAR: it measures 10 cm to 12 m along a narrow beam (about 2°), up to 100 times a second, and does not care about garage lighting. It runs on 5 V but talks 3.3 V UART, so it connects straight to the ESP32-C6 with no level shifter. Its default is UART at 115200 baud, which is what the YAML expects.
- Ring. Any WS2812 or SK6812 NeoPixel ring works. Set
num_ledsto match yours. A 16-LED ring (44.5 mm across) is easy to read from the driver’s seat. Go to 24 LEDs if the wall is far from where you sit. The Adafruit rings have solder pads, not a plug, so solder three short silicone wires to 5V, GND, and Data In. - Board. ESP32-C6 Super Mini. Leave these pins alone: GPIO8 (onboard LED and boot strap), GPIO9 (BOOT button and strap), GPIO12 and GPIO13 (USB D− and D+), and GPIO15 (strap). This build uses GPIO0 and GPIO1 for the sensor and GPIO2 for the ring.
- Power. One 5 V USB-C supply into the board powers everything. The sensor and the ring take 5 V from the board’s 5V pin. The sensor draws up to about 200 mA, and the YAML caps the ring at half brightness, which keeps the total well under 1 A. Do not also feed 5 V into the 5V pin while USB is plugged in.
- Data line. The ring’s data input runs on 5 V logic. A 3.3 V signal works on most rings if the wire is short. If the ring flickers or shows random colors, add a 330 Ω resistor in series with the data wire, and use a 74AHCT125 level shifter if that does not fix it. A 1000 µF capacitor across the ring’s 5V and GND pads helps with long power wires.
Wiring
Sensor wire colors are for the cable that comes with the TFmini-S. Check the label on yours.
| TFmini-S | ESP32-C6 Super Mini |
|---|---|
| Red (+5V) | 5V |
| Black (GND) | GND |
| Green (TXD, sensor out) | GPIO1 (RX) |
| White (RXD, sensor in) | GPIO0 (TX) |
| NeoPixel ring | ESP32-C6 Super Mini |
|---|---|
| 5V (PWR) | 5V |
| GND | GND |
| Data In (DIN) | GPIO2 |
TX goes to RX and RX goes to TX. If the logs show no distance at all, swap the two UART wires first.
Snippet
Trimmed from the pack YAML. Pins, ring size, and distances are substitutions. The TFmini-S is read with a community ESPHome component, pinned to a known-good commit:
substitutions:
device_name: garage-parking
friendly_name: Garage parking
# Pins (ESP32-C6 Super Mini)
led_pin: GPIO2 # ring DIN
lidar_tx_pin: GPIO0 # board TX -> sensor RX (white)
lidar_rx_pin: GPIO1 # board RX <- sensor TX (green)
# Ring
num_leds: "16" # 12, 16, 24 ... match your ring
max_brightness: "0.5" # 0.0-1.0; 0.5 is plenty in a garage
# Distances in cm, measured from the sensor to the car
start_cm: "300" # ring wakes up (blue) when the car is closer than this
stop_cm: "60" # red + blink: stop here
parked_cm: "120" # "Car parked" is on when closer than this
idle_off_s: "60" # ring turns off after the car sits still this long
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
on_boot:
priority: -100
then:
- light.turn_on:
id: ring
brightness: ${max_brightness}
effect: Parking
esp32:
board: esp32-c6-devkitc-1
variant: esp32c6
framework:
type: esp-idf
external_components:
- source: github://mikelawrence/esphome-components@ad94df944fb50e52ccd3877a41412f570ffd31e4
components: [tfmini]
# Wi-Fi, API, OTA, fallback hotspot: standard ESPHome blocks with !secret (full YAML in the pack)
globals:
- id: dist_cm
type: float
initial_value: "10000"
- id: anchor_cm
type: float
initial_value: "10000"
- id: last_move_ms
type: uint32_t
initial_value: "0"
uart:
id: lidar_uart
tx_pin: ${lidar_tx_pin}
rx_pin: ${lidar_rx_pin}
baud_rate: 115200
sensor:
- platform: tfmini
uart_id: lidar_uart
model: TFMINI_S
sample_rate: 20
distance:
id: lidar_raw
internal: true
on_value:
then:
- lambda: |-
// 10000 = no return (open air), 0 = saturated; treat both as "no car"
float d = (x <= 0 || x >= 1200) ? 10000.0f : x;
id(dist_cm) = d;
// Count as movement only if the reading shifts by more than 5 cm
if (fabsf(d - id(anchor_cm)) > 5.0f) {
id(anchor_cm) = d;
id(last_move_ms) = millis();
}
signal_strength:
name: "LiDAR signal"
entity_category: diagnostic
filters:
- throttle: 10s
- platform: template
name: "Parking distance"
unit_of_measurement: cm
device_class: distance
state_class: measurement
accuracy_decimals: 0
update_interval: 1s
lambda: |-
if (id(dist_cm) >= 10000.0f) return NAN;
return id(dist_cm);
filters:
- delta: 2
number:
- platform: template
id: start_cm
name: "Wake distance"
icon: mdi:arrow-expand-horizontal
entity_category: config
unit_of_measurement: cm
min_value: 100
max_value: 600
step: 5
initial_value: ${start_cm}
optimistic: true
restore_value: true
- platform: template
id: stop_cm
name: "Stop distance"
icon: mdi:car-brake-alert
entity_category: config
unit_of_measurement: cm
min_value: 15
max_value: 300
step: 5
initial_value: ${stop_cm}
optimistic: true
restore_value: true
- platform: template
id: parked_cm
name: "Parked distance"
entity_category: config
unit_of_measurement: cm
min_value: 20
max_value: 400
step: 5
initial_value: ${parked_cm}
optimistic: true
restore_value: true
binary_sensor:
- platform: template
name: "Car parked"
device_class: occupancy
icon: mdi:car
lambda: |-
return id(dist_cm) < id(parked_cm).state;
filters:
- delayed_on: 10s
- delayed_off: 30s
light:
- platform: esp32_rmt_led_strip
id: ring
name: "Parking ring"
pin: ${led_pin}
num_leds: ${num_leds}
chipset: WS2812
channel_colors: GRB
default_transition_length: 0s
restore_mode: ALWAYS_ON
effects:
- addressable_lambda:
name: Parking
update_interval: 50ms
lambda: |-
const float d = id(dist_cm);
const float start = id(start_cm).state;
const float stop = id(stop_cm).state;
const uint32_t now = millis();
const bool idle = (now - id(last_move_ms)) > (uint32_t) (${idle_off_s} * 1000);
// No car in range, or the car has been sitting still: ring off
if (d >= start || idle) {
it.all() = Color::BLACK;
return;
}
// At or past the stop line: blink red, 4 times a second
if (d <= stop) {
it.all() = ((now / 125) % 2) ? Color(255, 0, 0) : Color::BLACK;
return;
}
// 0.0 at the wake distance -> 1.0 at the stop line
float t = (start - d) / (start - stop);
// blue -> green -> yellow -> red
const Color stops[4] = {Color(0, 0, 255), Color(0, 255, 0),
Color(255, 180, 0), Color(255, 0, 0)};
float pos = t * 3.0f;
int i = (int) pos;
if (i > 2) i = 2;
float f = pos - i;
Color a = stops[i], b = stops[i + 1];
it.all() = Color(a.r + (b.r - a.r) * f,
a.g + (b.g - a.g) * f,
a.b + (b.b - a.b) * f);
The pack YAML uses !secret for Wi-Fi and API encryption. Substitute your own secrets locally.
Color and distance tuning
Distances are measured from the sensor to whatever the beam hits on the car, in centimeters.
| Distance from the sensor | Ring |
|---|---|
| Farther than Wake distance (default 300) | Off |
| Wake distance down to the stop line | Fades blue → green → yellow → red |
| At or inside Stop distance (default 60) | Blinks red |
Car still for idle_off_s (default 60 s) |
Off until something moves |
To set it up for your car:
- Park exactly where you want to stop. Open the device in Home Assistant and read Parking distance.
- Set Stop distance to that reading plus 5 to 10 cm, so the blink starts just before the bumper reaches the mark.
- Set Wake distance to where the car is when the garage door is fully up and the nose has just crossed the threshold, usually 250 to 400 cm.
- Set Parked distance a little longer than the stop distance (60 cm longer is a good start), so a car parked a bit short still counts as parked.
The colors live in the stops array in the effect. Swap the values there if you want, for example, white instead of blue at the far end.
Setup outline
- Solder three silicone wires to the ring’s 5V, GND, and DIN pads, and wire the sensor and ring to the board as in the tables.
- Copy
secrets.example.yamltosecrets.yamlin your ESPHome folder (or merge the keys into the one you have) and fill in your own values. - Create a new device in ESPHome, paste the pack YAML, and set
num_ledsto your ring size. Leave the distances at their defaults for now. - Flash over USB the first time. Walk in front of the sensor: the logs should show distances, and the ring should change color as you get closer. Later updates go over Wi-Fi.
- Accept the device in Home Assistant (Settings → Devices & services).
- Mount the sensor and the ring (see below), then tune the three distances with your car.
Mounting tips
- Aim the sensor at something flat on the car. The beam is narrow, so put it where it hits the front bumper or the license plate, usually 50 to 80 cm off the floor, not the windshield. Glass and very dark paint can return weak or no readings. Check LiDAR signal: under about 100 the reading is unreliable.
- Mount it on the front wall, square to the car. If it points at an angle, the distance it reports will not match the bumper’s real distance.
- Put the ring at eye level from the driver’s seat, a little above the hood line, so you can see it without leaning forward. A frosted diffuser in front of the ring (a strip of frosted acrylic or a printed lip) makes it read as a smooth glow instead of 16 dots.
- Keep the beam clear. A bike, trash can, or shelf in the beam path will wake the ring. Raise the sensor or narrow the wake distance.
- Garage temperature. The TFmini-S is rated 0 to 60 °C. In a garage that drops below freezing, mount it in the insulated wall side or expect occasional dropouts on the coldest mornings.
Home Assistant (optional)
Car parked turns on 10 seconds after the car comes inside the parked distance and turns off 30 seconds after it leaves. It is an occupancy sensor, so you can use it in automations. For example, to get a warning when the garage door has been open for 10 minutes with no car inside:
alias: Garage open, no car
triggers:
- trigger: state
entity_id: cover.garage_door
to: "open"
for: "00:10:00"
conditions:
- condition: state
entity_id: binary_sensor.garage_parking_car_parked
state: "off"
actions:
- action: notify.notify
data:
message: "The garage door is open and no car is parked."
Change cover.garage_door and the binary_sensor entity id to match yours.
Troubleshooting
- No distance in the logs. Swap the sensor’s green and white wires. Check that the sensor gets 5 V, not 3.3 V.
- Distance stuck at “unknown”. The sensor reports no return when it sees open air or a surface that absorbs the beam. Point it at a flat, light part of the car, like the bumper or plate.
- Ring flickers or shows random colors. Shorten the data wire, add a 330 Ω series resistor, or add a 74AHCT125 level shifter (see Hardware notes).
- Only some LEDs light, or the colors are wrong. Set
num_ledsto your ring’s LED count. If red and green are swapped, changechannel_colorsfromGRBtoRGB. - Ring wakes up when you walk past. Raise the sensor so it sits above your waist line, or lower the wake distance.
- Board not joining Wi-Fi. 2.4 GHz only; the C6 will not see a 5 GHz SSID. If it still fails, join the fallback hotspot and fix the credentials there.