[Help Request] [Template Light With RGB] [Custom Light Integration] I've been struggling for hours to integrate my backend, python bluetooth based rgb lights into HA !UPDATE! It's now 99% working how I want it!

UPDATE!

This is now 99.9% solved!! The only remaining improvement I’d want is to change the mushroom card temperture slider to be a saturation slider for the active color of the light. I’ve tried a few card-mod style changes with no success. Anyone have a suggestion for better card for this?

A couple other notes:

I’m currently on HA Core 2025.12.1 and HA OS 16.3 so I believe I need to update this config before updating to the newer 2026 versions.

i
## ====================================
### RGB BEDROOM STRIP LIGHT ####
## ====================================
input_boolean:
  bedroom_light_state_real:
    name: Bedroom Light State

input_number:
  bedroom_light_brightness_real:
    name: Bedroom Brightness
    min: 0
    max: 255
    step: 1
    initial: 255

input_text:
  bedroom_light_rgb_real:
    name: Bedroom RGB
    initial: "255,255,255"
  bedroom_light_rgb_base:  # NEW: Store the base color
    name: Bedroom RGB Base Color
    initial: "255,0,0"  # Default to red
  
sensor:
  - platform: rest
    name: Bedroom Light Status
    resource: http://192.168.2.120:23299/api/status
    scan_interval: 2
    value_template: "{{ value_json.state }}"
    json_attributes:
      - brightness
      - rgb_color
      - hex_color
      
      
      
light:
  - platform: template
    lights:
      bedroom_python_light:
        friendly_name: "Bedroom Python Light"
        
        value_template: "{{ is_state('input_boolean.bedroom_light_state_real', 'on') }}"
        
        level_template: "{{ states('input_number.bedroom_light_brightness_real') | int(255) }}"
        
        rgb_template: >
          {% set rgb = states('input_text.bedroom_light_rgb_real') %}
          {% set parts = rgb.split(',') if rgb not in ['unknown','unavailable',''] else ['255','255','255'] %}
          ({{ parts[0] | int(255) }}, {{ parts[1] | int(255) }}, {{ parts[2] | int(255) }})
        
        temperature_template: "{{ 153 }}"  # Start at cool (least white)
        
        turn_on:
          - service: input_boolean.turn_on
            target:
              entity_id: input_boolean.bedroom_light_state_real
          - service: rest_command.bedroom_light_api
            data:
              payload: >
                {
                  "state": "ON",
                  "brightness": {{ (states('input_number.bedroom_light_brightness_real') | int(255) / 255 * 100) | int }},
                  "rgb_color": [
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[0] | int(255) }},
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[1] | int(255) }},
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[2] | int(255) }}
                  ]
                }
        
        turn_off:
          - service: input_boolean.turn_off
            target:
              entity_id: input_boolean.bedroom_light_state_real
          - service: rest_command.bedroom_light_api
            data:
              payload: '{"state": "OFF"}'
        
        set_level:
          - service: input_number.set_value
            target:
              entity_id: input_number.bedroom_light_brightness_real
            data:
              value: "{{ brightness }}"
          - service: rest_command.bedroom_light_api
            data:
              payload: >
                {
                  "state": "ON",
                  "brightness": {{ (brightness / 255 * 100) | int }},
                  "rgb_color": [
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[0] | int(255) }},
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[1] | int(255) }},
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[2] | int(255) }}
                  ]
                }
        
        # Set RGB color - ALSO UPDATE BASE COLOR
        set_rgb:
          - service: input_text.set_value
            target:
              entity_id: input_text.bedroom_light_rgb_base
            data:
              value: "{{ rgb[0] }},{{ rgb[1] }},{{ rgb[2] }}"
          - service: input_text.set_value
            target:
              entity_id: input_text.bedroom_light_rgb_real
            data:
              value: "{{ rgb[0] }},{{ rgb[1] }},{{ rgb[2] }}"
          - service: rest_command.bedroom_light_api
            data:
              payload: >
                {
                  "state": "ON",
                  "brightness": {{ (states('input_number.bedroom_light_brightness_real') | int(255) / 255 * 100) | int }},
                  "rgb_color": [{{ rgb[0] }}, {{ rgb[1] }}, {{ rgb[2] }}]
                }


# Set color temperature - MIX BASE COLOR WITH WHITE (INVERTED)
        set_temperature:
          - service: input_text.set_value
            target:
              entity_id: input_text.bedroom_light_rgb_real
            data:
              value: >
                {% set base_rgb = states('input_text.bedroom_light_rgb_base').split(',') %}
                {% set r = base_rgb[0] | int(255) %}
                {% set g = base_rgb[1] | int(255) %}
                {% set b = base_rgb[2] | int(255) %}
                {% set white_amount = ((500 - color_temp) / (500 - 153)) | float %}
                {% set final_r = (r + (255 - r) * white_amount) | int %}
                {% set final_g = (g + (255 - g) * white_amount) | int %}
                {% set final_b = (b + (255 - b) * white_amount) | int %}
                {{ final_r }},{{ final_g }},{{ final_b }}
          - service: rest_command.bedroom_light_api
            data:
              payload: >
                {% set base_rgb = states('input_text.bedroom_light_rgb_base').split(',') %}
                {% set r = base_rgb[0] | int(255) %}
                {% set g = base_rgb[1] | int(255) %}
                {% set b = base_rgb[2] | int(255) %}
                {% set white_amount = ((500 - color_temp) / (500 - 153)) | float %}
                {% set final_r = (r + (255 - r) * white_amount) | int %}
                {% set final_g = (g + (255 - g) * white_amount) | int %}
                {% set final_b = (b + (255 - b) * white_amount) | int %}
                {
                  "state": "ON",
                  "brightness": {{ (states('input_number.bedroom_light_brightness_real') | int(255) / 255 * 100) | int }},
                  "rgb_color": [{{ final_r }}, {{ final_g }}, {{ final_b }}]
                }

And in my automation.yaml:


- id: bedroom_light_sync_from_api
  alias: "Sync Bedroom Python Light State"
  mode: queued  # Process updates in order
  max: 5  # Maximum 5 queued updates
  trigger:
    - platform: state
      entity_id: sensor.bedroom_light_status
  action:
    - service: input_boolean.turn_{{ 'on' if trigger.to_state.state == 'ON' else 'off' }}
      target:
        entity_id: input_boolean.bedroom_light_state_real
    - service: input_number.set_value
      target:
        entity_id: input_number.bedroom_light_brightness_real
      data:
        value: "{{ (state_attr('sensor.bedroom_light_status', 'brightness') | int(100) * 2.55) | float }}"
    - service: input_text.set_value
      target:
        entity_id: input_text.bedroom_light_rgb_real
      data:
        value: >
          {% set rgb = state_attr('sensor.bedroom_light_status', 'rgb_color') %}
          {% if rgb %}{{ rgb[0] }},{{ rgb[1] }},{{ rgb[2] }}{% else %}255,255,255{% endif %}


I don’t think I need help with the Python part but if anyone is interested:

This is my current python code (it still needs some work and it used to launch a webpage to control the RGB lights but it functions for creating an endpoint for the lights to be controlled)

It’s a Keepsmile brand RGB String/Strip light Python Bluetooth integration. It could definitely use some improvement but it’s been quite reliable for months when I had the webpage built for it.

import asyncio
import threading
from bleak import BleakClient
from bleak import BleakScanner
import time
import requests

from flask import redirect, url_for, Flask, request, jsonify

HA_URL = "http://192.168.1.200:8123/api/webhook/bedroom_light_update"

ble_client = None
ble_lock = threading.Lock()
loop = asyncio.new_event_loop()

app = Flask(__name__)

ADDRESS = "BE:27:79:00:F3:56"
CHARACTERISTIC_WRITE_UUID = "0000afd1-0000-1000-8000-00805f9b34fb"

LIGHTS_ON_STRING = "5BF000B5"
LIGHTS_OFF_STRING = "5B0F00B5"

# Store the last selected values
last_color = "#ffffff"
last_brightness = 100
lights_on = False  # MOVED TO TOP - must be defined before functions use it

def push_state_to_ha():
    """Push current state to Home Assistant webhook"""
    try:
        r = int(last_color[1:3], 16)
        g = int(last_color[3:5], 16)
        b = int(last_color[5:7], 16)

        payload = {
            "state": "ON" if lights_on else "OFF",
            "brightness": int(last_brightness * 2.55),
            "rgb_color": [r, g, b]
        }

        requests.post(HA_URL, json=payload, timeout=1)
        print(f"📤 Pushed to HA: {payload}")
    except Exception as e:
        print(f"❌ HA push failed: {e}")

        
async def send_command_to_connected_device(command_hex):
    """Send command using the persistent BLE connection"""
    global ble_client
    command_bytes = hex_str_to_bytes(command_hex)
    try:
        if ble_client and ble_client.is_connected:  # <-- Remove await here
            await ble_client.write_gatt_char(CHARACTERISTIC_WRITE_UUID, command_bytes)
            print(f"✅ Sent command: {command_hex}")
        else:
            print("⚠️ BLE client not connected.")
    except Exception as e:
        print(f"❌ Failed to send BLE command: {e}")
        import traceback
        traceback.print_exc()


async def maintain_ble_connection():
    """Maintain persistent connection to BLE device"""
    global ble_client
    while True:
        try:
            print("🔄 Attempting BLE connection...")
            devices = await BleakScanner.discover(timeout=3.0)
            target = next((d for d in devices if d.name and d.name.startswith("KS03")), None)

            if target:
                print(f"✅ Found device: {target.name} @ {target.address}")
                client = BleakClient(target.address)
                await client.connect(timeout=5.0)
                print("🔌 Connected and ready.")
                ble_client = client

                while await client.is_connected():
                    await asyncio.sleep(1)
                
                print("⚠️ BLE disconnected.")
                ble_client = None
            else:
                print("❌ BLE device not found. Retrying...")

        except Exception as e:
            print(f"❌ Connection error: {e}")
        await asyncio.sleep(3)


def hex_str_to_bytes(hex_string):
    """Convert hex string to bytes"""
    return bytearray.fromhex(hex_string)

def get_hex(number):
    """Convert number to 2-digit hex string"""
    return hex(number)[2:].zfill(2).upper()

def get_color_command(red, green, blue, brightness):
    """Generate BLE color command"""
    print(f"🎨 Building color command: RGB({red}, {green}, {blue}), Brightness: {brightness}")
    return f"5A0001{get_hex(red)}{get_hex(green)}{get_hex(blue)}00{get_hex(brightness)}00A5"


def run_ble_command_async(command_hex):
    """Queue a BLE command to run in the async loop"""
    asyncio.run_coroutine_threadsafe(
        send_command_to_connected_device(command_hex),
        loop
    )

def build_html(current_color, current_brightness):
    """Build simple HTML interface"""
    return f'''
    <html>
    <head>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <style>
            body {{
                font-family: sans-serif;
                background: #121212;
                color: #eee;
                padding: 20px;
                display: flex;
                justify-content: center;
            }}
            form {{
                width: 100%;
                max-width: 400px;
                background: #1e1e1e;
                border-radius: 12px;
                padding: 20px;
            }}
            button {{
                width: 100%;
                padding: 12px;
                margin: 8px 0;
                border: none;
                border-radius: 8px;
                font-size: 16px;
                background: #333;
                color: white;
                cursor: pointer;
            }}
            button:hover {{
                background: #555;
            }}
            label {{
                display: block;
                margin-top: 16px;
                margin-bottom: 4px;
            }}
            input[type='color'], input[type='range'] {{
                width: 100%;
                margin-bottom: 12px;
            }}
        </style>
    </head>
    <body>
        <form method="POST">
            <h3>Presets</h3>
            <button name="command" value="ON">Lights ON</button>
            <button name="command" value="OFF">Lights OFF</button>
            <button name="command" value="BRIGHT_WHITE">Bright White 🌞</button>
            <button name="command" value="BABY_PURPLE">Baby Purple 💜</button>
            
            <h3>Custom</h3>
            <label for="color">Color:</label>
            <input type="color" id="color" name="color" value="{current_color}">
            <label for="brightness">Brightness: <span id="bval">{current_brightness}</span></label>
            <input type="range" id="brightness" name="brightness" min="0" max="100" value="{current_brightness}"
                   oninput="document.getElementById('bval').innerText = this.value">
            <button type="submit">Set Custom</button>
        </form>
    </body>
    </html>
    '''

# ==============================================
# API ENDPOINTS
# ==============================================

@app.route('/api/status', methods=['GET'])
def api_status():
    """Home Assistant checks this to see current state"""
    try:
        r = int(last_color[1:3], 16)
        g = int(last_color[3:5], 16)
        b = int(last_color[5:7], 16)
    except:
        r, g, b = 255, 255, 255

    return jsonify({
        "state": "ON" if lights_on else "OFF",
        "brightness": int(last_brightness),
        "rgb_color": [r, g, b],
        "hex_color": last_color
    })

@app.route('/api/update', methods=['POST'])
def api_update():
    """Home Assistant sends commands here"""
    global lights_on, last_color, last_brightness
    
    data = request.json
    print(f"\n{'='*50}")
    print(f"📥 HA REQUEST: {data}")
    print(f"📊 BEFORE - lights_on: {lights_on}, color: {last_color}, brightness: {last_brightness}")
    
    if not data:
        return jsonify({"error": "No JSON data"}), 400

    # Track if we're turning on from OFF state
    was_off = not lights_on

    # Handle OFF first (early return)
    if data.get('state') == 'OFF':
        lights_on = False
        print(f"🔴 Turning OFF - sending simple command")
        run_ble_command_async(LIGHTS_OFF_STRING)
        push_state_to_ha()
        print(f"{'='*50}\n")
        return jsonify({"status": "turned_off"})

    # Handle ON state
    if data.get('state') == 'ON':
        lights_on = True
        print(f"🟢 State set to ON (was_off: {was_off})")

    # Update color if provided
    if 'rgb_color' in data:
        r, g, b = data['rgb_color']
        last_color = f"#{r:02x}{g:02x}{b:02x}"
        print(f"🎨 Color updated to: {last_color}")

    # Update brightness if provided
    if 'brightness' in data:
        last_brightness = int(data['brightness'])
        print(f"💡 Brightness updated to: {last_brightness}")

    # Send BLE command if light is ON
    if lights_on:
        # CRITICAL: If we just turned ON from OFF, send simple ON command
        # Then send color command if color/brightness was also sent
        if was_off:
            print(f"📤 Sending SIMPLE ON command (was off): {LIGHTS_ON_STRING}")
            run_ble_command_async(LIGHTS_ON_STRING)
            
            # If color or brightness was also specified, send that too after a delay
            if 'rgb_color' in data or 'brightness' in data:
                import time
                time.sleep(0.2)  # Brief delay to let ON command process
                
                r = int(last_color[1:3], 16)
                g = int(last_color[3:5], 16)
                b = int(last_color[5:7], 16)
                command = get_color_command(r, g, b, last_brightness)
                print(f"📤 Sending color command after ON: {command}")
                run_ble_command_async(command)
        else:
            # Light was already on, just update color/brightness
            r = int(last_color[1:3], 16)
            g = int(last_color[3:5], 16)
            b = int(last_color[5:7], 16)
            command = get_color_command(r, g, b, last_brightness)
            print(f"📤 Sending color update (already on): {command}")
            run_ble_command_async(command)
    
    print(f"📊 AFTER - lights_on: {lights_on}, color: {last_color}, brightness: {last_brightness}")
    print(f"{'='*50}\n")
    
    push_state_to_ha()
    
    return jsonify({
        "status": "ok",
        "state": "ON" if lights_on else "OFF",
        "color": last_color,
        "brightness": last_brightness
    })

# ==============================================
# WEB INTERFACE
# ==============================================

@app.route('/', methods=['GET'])
def index():
    return build_html(last_color, last_brightness)


@app.route('/', methods=['POST'])
def control():
    """Handle web form submissions"""
    global last_color, last_brightness, lights_on

    cmd = request.form.get('command')

    if cmd:
        if cmd == 'ON':
            lights_on = True
            command = LIGHTS_ON_STRING
            last_color = '#ffffff'
            last_brightness = 100
        elif cmd == 'OFF':
            lights_on = False
            command = LIGHTS_OFF_STRING
        elif cmd == 'BRIGHT_WHITE':
            lights_on = True
            last_color = '#ffffff'
            last_brightness = 100
            command = get_color_command(255, 255, 255, 100)
        elif cmd == 'BABY_PURPLE':
            lights_on = True
            last_color = '#7f00ff'
            last_brightness = 100
            command = get_color_command(127, 0, 255, 100)
        else:
            return "Unknown command", 400
    else:
        # Custom color/brightness from form
        lights_on = True
        try:
            hex_color = request.form.get('color', '#ffffff')
            last_color = hex_color
            r = int(hex_color[1:3], 16)
            g = int(hex_color[3:5], 16)
            b = int(hex_color[5:7], 16)
            last_brightness = int(request.form.get('brightness', 100))
            command = get_color_command(r, g, b, last_brightness)
        except Exception as e:
            return f"Invalid input: {e}", 400

    print(f"🌐 Web control: {cmd if cmd else 'custom'}")
    run_ble_command_async(command)
    push_state_to_ha()

    return redirect(url_for('index'))


# ==============================================
# BACKGROUND TASKS
# ==============================================

def start_flask():
    """Start Flask web server"""
    app.run(host='0.0.0.0', port=23299, debug=False)

    
async def keep_connection_alive():
    """Send periodic keep-alive commands"""
    global ble_client
    while True:
        try:
            if ble_client and await ble_client.is_connected():
                r = int(last_color[1:3], 16)
                g = int(last_color[3:5], 16)
                b = int(last_color[5:7], 16)
                command = get_color_command(r, g, b, last_brightness)

                await ble_client.write_gatt_char(
                    CHARACTERISTIC_WRITE_UUID,
                    hex_str_to_bytes(command)
                )
                print("💓 Keep-alive ping sent")
        except Exception as e:
            print(f"⚠️ Keep-alive error: {e}")
        await asyncio.sleep(2)


# ==============================================
# MAIN STARTUP
# ==============================================

if __name__ == '__main__':
    print("🚀 Starting RGB Light Controller...")
    
    # Start async event loop in background thread
    threading.Thread(target=loop.run_forever, daemon=True).start()
    
    # Start BLE connection manager
    asyncio.run_coroutine_threadsafe(maintain_ble_connection(), loop)
    
    # Start keep-alive pinger
    asyncio.run_coroutine_threadsafe(keep_connection_alive(), loop)
    
    # Start Flask web server (blocking)
    print("🌐 Starting web server on http://0.0.0.0:23299")
    start_flask()

Thank you all so much for HA and the amazing community <3

for future reference, template lights are optimistic. You can simplify this setup by using that functionality.

Secondly, legacy template entities are deprecated and your post will stop working in june.

## ====================================
### RGB BEDROOM STRIP LIGHT ####
## ====================================

sensor:
  - platform: rest
    name: Bedroom Light Status
    resource: http://192.168.2.120:23299/api/status
    scan_interval: 2
    value_template: "{{ value_json.state }}"
    json_attributes:
      - brightness
      - rgb_color
      - hex_color
      
      
      
template:
  - light:
      - default_entity_id: light.bedroom_python_light
        name: Bedroom Python Light
        turn_on:
          - service: rest_command.bedroom_light_api
            data:
              payload: >
                {
                  "state": "ON",
                  "brightness": {{ (states('input_number.bedroom_light_brightness_real') | int(255) / 255 * 100) | int }},
                  "rgb_color": [
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[0] | int(255) }},
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[1] | int(255) }},
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[2] | int(255) }}
                  ]
                }
        
        turn_off:
          - service: rest_command.bedroom_light_api
            data:
              payload: '{"state": "OFF"}'
        
        set_level:

          - service: rest_command.bedroom_light_api
            data:
              payload: >
                {
                  "state": "ON",
                  "brightness": {{ (brightness / 255 * 100) | int }},
                  "rgb_color": [
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[0] | int(255) }},
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[1] | int(255) }},
                    {{ states('input_text.bedroom_light_rgb_real').split(',')[2] | int(255) }}
                  ]
                }
        
        # Set RGB color - ALSO UPDATE BASE COLOR
        set_rgb:
          - service: rest_command.bedroom_light_api
            data:
              payload: >
                {
                  "state": "ON",
                  "brightness": {{ (states('input_number.bedroom_light_brightness_real') | int(255) / 255 * 100) | int }},
                  "rgb_color": [{{ rgb[0] }}, {{ rgb[1] }}, {{ rgb[2] }}]
                }

        # Set color temperature - MIX BASE COLOR WITH WHITE (INVERTED)
        set_temperature:
          - service: rest_command.bedroom_light_api
            data:
              payload: >
                {% set base_rgb = states('input_text.bedroom_light_rgb_base').split(',') %}
                {% set r = base_rgb[0] | int(255) %}
                {% set g = base_rgb[1] | int(255) %}
                {% set b = base_rgb[2] | int(255) %}
                {% set white_amount = ((500 - color_temp) / (500 - 153)) | float %}
                {% set final_r = (r + (255 - r) * white_amount) | int %}
                {% set final_g = (g + (255 - g) * white_amount) | int %}
                {% set final_b = (b + (255 - b) * white_amount) | int %}
                {
                  "state": "ON",
                  "brightness": {{ (states('input_number.bedroom_light_brightness_real') | int(255) / 255 * 100) | int }},
                  "rgb_color": [{{ final_r }}, {{ final_g }}, {{ final_b }}]
                }