ESPHomeESP32DIYGarage

LiDAR garage parking light

Retire the tennis ball on a string: a TFmini-S LiDAR and a NeoPixel ring on an ESP32-C6 Super Mini fade blue, green, yellow, then red as you pull in, and blink when it is time to stop. ESPHome, with a Car parked sensor for Home Assistant.

Difficulty · Beginner–IntermediateSetup · 1–2 hoursHA · current stable (native API)ESPHome · 2026.9
Get the pack · from $19Have us set it up

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_leds to 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:

  1. Park exactly where you want to stop. Open the device in Home Assistant and read Parking distance.
  2. Set Stop distance to that reading plus 5 to 10 cm, so the blink starts just before the bumper reaches the mark.
  3. 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.
  4. 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

  1. 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.
  2. Copy secrets.example.yaml to secrets.yaml in your ESPHome folder (or merge the keys into the one you have) and fill in your own values.
  3. Create a new device in ESPHome, paste the pack YAML, and set num_leds to your ring size. Leave the distances at their defaults for now.
  4. 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.
  5. Accept the device in Home Assistant (Settings → Devices & services).
  6. 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_leds to your ring’s LED count. If red and green are swapped, change channel_colors from GRB to RGB.
  • 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.
Changelog

v1.0 · Oct 18, 2026: First publish.