Docs · Home Assistant

Home Assistant Integration

TiltForge works with Home Assistant two ways. Pick based on whether you want to keep the stock firmware or run ESPHome.

Option 1 — Stock firmware + REST (no reflash)

Your TiltForge unit already exposes a local REST API. Home Assistant can drive it directly — nothing to flash.

  • +File: tiltforge-home-assistant.yaml
  • +How: replace tiltforge-5110 with your device's 4-character name, paste into configuration.yaml, restart HA.
  • +Behavior: polling-based. Manual cord-pulls appear in HA within a few seconds.
  • +Best for: anyone who wants the stock experience — works with the standard and Matter-capable units, alongside Matter on the Matter-capable unit.
tiltforge-home-assistant.yamlyaml
# =============================================================================# TiltForge — Home Assistant integration (stock firmware, no reflash)# =============================================================================# Turns a shipped TiltForge unit into a native Home Assistant cover entity# using its built-in REST endpoints. Nothing changes on the device.## This is POLLING-based: HA reads /state every 5 s, so a manual cord-pull shows# up in HA within a few seconds (not instant). For instant/real-time HA, use# the ESPHome path instead (tiltforge-esphome.yaml).## SETUP:#   1. Replace every "tiltforge-5110" below with YOUR device's name — the#      4-character code shown during setup (also the setup Wi-Fi network name).#   2. Paste into configuration.yaml (or a package/included file).#   3. Restart Home Assistant.## NOTE on .local: HA resolves tiltforge-XXXX.local on most systems. If your HA# host can't do mDNS, replace the hostname with the device's IP address (find# it in your router, or via the device's `i` serial command).## NOTE on "position": TiltForge is physically a slat-TILT device. Variant A# (below, active) maps that tilt onto HA's position slider — the cleanest# single-control UX. Variant B represents it as slat-tilt instead. Use ONE.# ============================================================================= # --- Shared: REST commands (used by both variants) ---rest_command:  tiltforge_5110_open:    url: "http://tiltforge-5110.local/open"    method: GET  tiltforge_5110_close:    url: "http://tiltforge-5110.local/close"    method: GET  tiltforge_5110_stop:    url: "http://tiltforge-5110.local/stop"    method: GET  tiltforge_5110_set:    url: "http://tiltforge-5110.local/tilt?pct={{ pct }}"    method: POST # --- Shared: poll /state for current position (0=closed .. 100=open) ---sensor:  - platform: rest    name: tiltforge_5110_pct    resource: "http://tiltforge-5110.local/state"    value_template: "{{ value_json.pct }}"    scan_interval: 5 # =============================================================================# VARIANT A — position slider (RECOMMENDED, active)# Clean single slider + open/close/stop. Maps tilt onto HA position.# =============================================================================cover:  - platform: template    covers:      tiltforge_5110:        friendly_name: "Living Room Blind"        device_class: blind        position_template: "{{ states('sensor.tiltforge_5110_pct') | int(0) }}"        open_cover:          action: rest_command.tiltforge_5110_open        close_cover:          action: rest_command.tiltforge_5110_close        stop_cover:          action: rest_command.tiltforge_5110_stop        set_cover_position:          action: rest_command.tiltforge_5110_set          data:            pct: "{{ position }}" # =============================================================================# VARIANT B — slat-tilt control (ALTERNATIVE)# Represents the device as tilt: open/close/stop buttons + a tilt slider.# To use this instead of Variant A, delete the "cover:" block above and# uncomment the one below.# =============================================================================# cover:#   - platform: template#     covers:#       tiltforge_5110:#         friendly_name: "Living Room Blind"#         device_class: blind#         tilt_template: "{{ states('sensor.tiltforge_5110_pct') | int(0) }}"#         open_cover:#           action: rest_command.tiltforge_5110_open#         close_cover:#           action: rest_command.tiltforge_5110_close#         stop_cover:#           action: rest_command.tiltforge_5110_stop#         set_cover_tilt_position:#           action: rest_command.tiltforge_5110_set#           data:#             pct: "{{ tilt }}" # -----------------------------------------------------------------------------# Older Home Assistant (pre-2024.8): replace every "action:" above with# "service:" — the two are equivalent; newer HA prefers "action:".# -----------------------------------------------------------------------------

Option 2 — ESPHome (reflash, real-time)

Flash the ESPHome config to the XIAO ESP32-C6, replacing the TiltForge firmware. The device then appears in HA natively with instant, push-based updates and no HA-side YAML.

  • +File: tiltforge-esphome.yaml
  • +How: set your Wi-Fi (or use the captive-portal hotspot) and the two AS5600 calibration constants, then flash with the ESPHome dashboard / CLI.
  • +Behavior: real-time. Uses ESPHome's native AS5600 component and the ESPHome API — no polling.
  • +Best for: makers already living in ESPHome who want the tightest HA integration. Trade-off: you leave TiltForge firmware behind (no Matter, no TiltForge branded web UI). Reflash the TiltForge binary to return.
tiltforge-esphome.yamlyaml
# =============================================================================# TiltForge — ESPHome reference configuration# =============================================================================# For makers who want to run their TiltForge on ESPHome instead of the stock# TiltForge firmware. Flashing this REPLACES the TiltForge firmware on the# XIAO ESP32-C6. The device then appears natively in Home Assistant as a cover# (real-time, no polling) via the ESPHome API — no YAML needed on the HA side.## Hardware assumed (TiltForge Rev C):#   XIAO ESP32-C6  |  AS5600 encoder on I2C  |  L9110 H-bridge  |  N20 motor## Pin mapping (XIAO C6 silk label -> ESP32-C6 GPIO):#   D5 -> GPIO23  Motor IA        D8 -> GPIO19  I2C SDA#   D6 -> GPIO16  Motor IB        D9 -> GPIO20  I2C SCL## BEFORE FLASHING — set two things:#   1. Your Wi-Fi in secrets.yaml (wifi_ssid / wifi_password), OR use the#      "TiltForge Setup" captive-portal hotspot on first boot.#   2. The two calibration constants below (raw_closed / raw_open) to the#      AS5600 raw values at your blind's closed and open endpoints. Easiest#      way: flash as-is, watch the "Raw angle" sensor in HA while you pull the#      cord to each end, then fill in the numbers and re-flash.## NOTE: This is a validated-structure reference, not bench-tested on every# blind. If Open/Close run the wrong way, swap motor_ia <-> motor_ib (either in# wiring or by swapping the two output ids below). Tune the stop tolerance if# it overshoots/undershoots.# ============================================================================= substitutions:  name: "tiltforge"  friendly_name: "TiltForge Blind"  # --- CALIBRATION: AS5600 raw counts (0-4095) at each mechanical endpoint ---  raw_closed: "0"       # raw angle at FULLY CLOSED  raw_open: "2048"      # raw angle at FULLY OPEN  # --- Stop tolerance (fraction of full travel, 0.02 = 2%) ---  pos_tolerance: "0.02" esphome:  name: ${name}  friendly_name: ${friendly_name} esp32:  board: esp32-c6-devkitc-1     # generic C6; "seeed_xiao_esp32c6" also works  framework:    type: esp-idf               # esp-idf recommended for ESP32-C6 logger: api:                            # native HA connection (real-time, no polling) ota:  - platform: esphome wifi:  ssid: !secret wifi_ssid  password: !secret wifi_password  ap:    ssid: "TiltForge Setup"     # fallback captive portal on first boot / no wifi captive_portal: # --- I2C bus for the AS5600 (TiltForge routes it to D8/D9) ---i2c:  sda: GPIO19                   # XIAO D8  scl: GPIO20                   # XIAO D9  frequency: 100kHz # --- AS5600 magnetic encoder (native ESPHome component, 2024.2+) ---as5600: sensor:  - platform: as5600    raw_position:      name: "Raw angle"      id: as5600_raw    update_interval: 100ms # --- L9110 H-bridge outputs ---# Truth table (TiltForge): IA=on/IB=off -> open ; IA=off/IB=on -> close ;# both off -> stop (coast).output:  - platform: gpio    pin: GPIO23                 # XIAO D5 = Motor IA    id: motor_ia  - platform: gpio    pin: GPIO16                 # XIAO D6 = Motor IB    id: motor_ib globals:  - id: target_pos    type: float    restore_value: no    initial_value: '0.5'  - id: is_moving    type: bool    restore_value: no    initial_value: 'false' # --- Motor helper scripts ---script:  - id: motor_open              # drive toward OPEN    then:      - output.turn_off: motor_ib      - output.turn_on: motor_ia  - id: motor_close             # drive toward CLOSED    then:      - output.turn_off: motor_ia      - output.turn_on: motor_ib  - id: motor_halt    then:      - output.turn_off: motor_ia      - output.turn_off: motor_ib      - lambda: 'id(is_moving) = false;' # --- Template cover: position slider + open/close/stop ---cover:  - platform: template    name: ${friendly_name}    id: tiltforge_cover    device_class: blind    has_position: true    # Current position (0.0 closed .. 1.0 open) derived from the AS5600.    lambda: |-      float span = ${raw_open} - ${raw_closed};      if (span == 0.0f) return 0.0f;      float p = (id(as5600_raw).state - (float)${raw_closed}) / span;      if (p < 0.0f) p = 0.0f;      if (p > 1.0f) p = 1.0f;      return p;    open_action:      - lambda: 'id(target_pos) = 1.0f; id(is_moving) = true;'      - script.execute: motor_open    close_action:      - lambda: 'id(target_pos) = 0.0f; id(is_moving) = true;'      - script.execute: motor_close    stop_action:      - script.execute: motor_halt    position_action:      - lambda: |-          float span = ${raw_open} - ${raw_closed};          float cur = (id(as5600_raw).state - (float)${raw_closed}) / span;          id(target_pos) = pos;          id(is_moving) = true;          if (pos > cur) { id(motor_open).execute(); }          else           { id(motor_close).execute(); } # --- Closed-loop stop: halt the motor once the encoder reaches target ---interval:  - interval: 100ms    then:      - if:          condition:            lambda: 'return id(is_moving);'          then:            - lambda: |-                float span = ${raw_open} - ${raw_closed};                float cur = (id(as5600_raw).state - (float)${raw_closed}) / span;                if (cur < 0.0f) cur = 0.0f;                if (cur > 1.0f) cur = 1.0f;                if (fabs(cur - id(target_pos)) <= ${pos_tolerance}) {                  id(motor_halt).execute();                  id(tiltforge_cover).position = cur;                  id(tiltforge_cover).publish_state();                }

Which do I want?

Stock + RESTESPHome
Reflash needed
Update speedPoll (~5 s)Real-time
Matter (Apple/Google/Alexa)
HA-side configSmall YAMLNone
Keeps TiltForge firmware

† Matter is available on the Matter-capable TiltForge unit.

Notes

  • +.local names: HA usually resolves tiltforge-XXXX.local. If yours can't, use the device IP (from your router, or the i serial command).
  • +Tilt vs position: TiltForge is a slat-tilt device. The REST config maps that onto HA's position slider by default (cleanest UX); a slat-tilt variant is included in the file. ESPHome models position directly.