BUSY.Tedt.org BUSY Bar Apps
← All Apps

Home Assistant

Browse and control Home Assistant lights with the BUSY Bar dial, Select, and Start controls.

Updated Aug 16, 2026 · Added Aug 1, 2026 · ↓ 0 package download starts

By kylewhirl · ▲ 0 · Rate and Discuss

How It Works

Browse and control Home Assistant lights with the BUSY Bar dial, Select, and Start controls.

Download and extract the complete app folder. Keep its files together; some apps include additional modules, assets, or configuration examples.

Install the app’s declared Python dependencies in a virtual environment:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt

Review app.py and any configuration examples for this app’s options, then run it from the extracted folder:

python3 app.py

Connect BUSY Bar over USB, or use the connection options supported by this app. See the official HTTP API documentation for device setup.

Declared Dependencies

busylib==1.0.0
material-design-icons-pack==7.4.47
requests>=2.32.0,<3
resvg_py==0.3.3

Source Files

These are the mirrored source bytes. The complete package also includes every asset and supporting file.

app.py
#!/usr/bin/env python3
"""Home Assistant: browse and control lights from a BUSY Bar.

Run without Home Assistant credentials for a safe interactive demo:

    python app.py --host 127.0.0.1:8080

Connect to Home Assistant with environment variables:

    HA_URL=http://homeassistant.local:8123 HA_TOKEN=... python app.py

Optionally choose and order lights with ``HA_ENTITIES`` (comma-separated).
The BUSY HTTP access key can be supplied as ``BUSYBAR_TOKEN``.
"""

from __future__ import annotations

import argparse
import asyncio
import hashlib
import os
from dataclasses import dataclass
from typing import Any

import requests
import resvg_py
from busylib import AsyncBusyBar, exceptions, types
from material_design_icons_pack import get_icon

APP = "home-assistant"
ACCENT = "#63E6BEFF"
WHITE = "#FFFFFFFF"
MUTED = "#A8B2C3FF"
OFF = "#495365FF"

COLOR_PRESETS: tuple[tuple[str, tuple[int, int, int]], ...] = (
    ("MINT", (99, 230, 190)),
    ("AMBER", (255, 191, 71)),
    ("BLUE", (80, 150, 255)),
    ("ROSE", (255, 94, 147)),
    ("LIME", (159, 232, 112)),
    ("VIOLET", (167, 139, 250)),
)

@dataclass
class Light:
    """Small, mutable view of a Home Assistant light state."""

    entity_id: str
    name: str
    icon_name: str
    is_on: bool
    brightness: int
    rgb: tuple[int, int, int]
    temperature: int
    min_temperature: int
    max_temperature: int
    supports_rgb: bool
    supports_temperature: bool

    @property
    def controls(self) -> tuple[str, ...]:
        controls = ["brightness"]
        if self.supports_rgb:
            controls.append("color")
        if self.supports_temperature:
            controls.append("temperature")
        return tuple(controls)


def normalize_icon_name(icon_name: str) -> str:
    candidate = icon_name.lower().strip()
    if candidate.startswith("mdi:") and get_icon(candidate.removeprefix("mdi:")):
        return candidate
    return "mdi:lightbulb"


def icon_asset_token(icon_name: str) -> str:
    return hashlib.blake2s(
        normalize_icon_name(icon_name).encode(), digest_size=5
    ).hexdigest()


def icon_png(icon_name: str, size: int, color: str) -> bytes:
    """Rasterize the entity's actual Home Assistant MDI icon."""
    icon = get_icon(normalize_icon_name(icon_name).removeprefix("mdi:"))
    assert icon is not None
    svg = icon.svg.replace("<path ", f'<path fill="{color[:7]}" ')
    return resvg_py.svg_to_bytes(svg_string=svg, width=size, height=size)


def icon_color(icon_name: str) -> str:
    slug = normalize_icon_name(icon_name).removeprefix("mdi:")
    if any(word in slug for word in ("light", "lamp", "ceiling")):
        return "#FFD518"
    return "#63E6BE"


def icon_path(light: Light, variant: str) -> str:
    code = {"active": "a", "inactive": "i", "back": "b"}[variant]
    return f"ha_{code}_{icon_asset_token(light.icon_name)}.png"


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--host", default="10.0.4.20", help="BUSY host or host:port")
    parser.add_argument("--busy-token", default=os.getenv("BUSYBAR_TOKEN", ""))
    parser.add_argument("--ha-url", default=os.getenv("HA_URL", ""))
    parser.add_argument("--ha-token", default=os.getenv("HA_TOKEN", ""))
    parser.add_argument("--entities", default=os.getenv("HA_ENTITIES", ""))
    parser.add_argument("--once", action="store_true", help="draw one frame and exit")
    parser.add_argument(
        "--screen",
        choices=("browse", "control"),
        default="browse",
        help="initial screen (useful for emulator previews)",
    )
    return parser.parse_args()


class HomeAssistant:
    """Minimal REST client; requests is kept off the asyncio event loop."""

    def __init__(self, url: str, token: str, entity_ids: list[str]) -> None:
        self.url = url.rstrip("/")
        self.entity_ids = entity_ids
        self.session = requests.Session()
        self.session.headers.update(
            {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
        )

    @property
    def configured(self) -> bool:
        return bool(self.url and self.session.headers.get("Authorization") != "Bearer ")

    def get_lights(self) -> list[Light]:
        response = self.session.get(f"{self.url}/api/states", timeout=8)
        response.raise_for_status()
        states = response.json()
        wanted = set(self.entity_ids)
        lights = [
            state
            for state in states
            if str(state.get("entity_id", "")).startswith("light.")
            and (not wanted or state["entity_id"] in wanted)
        ]
        if wanted:
            order = {
                entity_id: index for index, entity_id in enumerate(self.entity_ids)
            }
            lights.sort(key=lambda state: order.get(state["entity_id"], len(order)))
        return [light_from_state(state) for state in lights[:12]]

    def service(self, service: str, data: dict[str, Any]) -> None:
        response = self.session.post(
            f"{self.url}/api/services/light/{service}", json=data, timeout=8
        )
        response.raise_for_status()


def light_from_state(state: dict[str, Any]) -> Light:
    attrs = state.get("attributes", {})
    modes = {str(mode) for mode in attrs.get("supported_color_modes", [])}
    raw_rgb = attrs.get("rgb_color") or COLOR_PRESETS[0][1]
    minimum = int(attrs.get("min_color_temp_kelvin") or 2000)
    maximum = int(attrs.get("max_color_temp_kelvin") or 6500)
    return Light(
        entity_id=state["entity_id"],
        name=str(attrs.get("friendly_name") or state["entity_id"].split(".", 1)[1]),
        icon_name=normalize_icon_name(str(attrs.get("icon") or "mdi:lightbulb")),
        is_on=state.get("state") == "on",
        brightness=round(int(attrs.get("brightness") or 0) * 100 / 255),
        rgb=tuple(int(channel) for channel in raw_rgb[:3]),
        temperature=int(attrs.get("color_temp_kelvin") or (minimum + maximum) // 2),
        min_temperature=minimum,
        max_temperature=maximum,
        supports_rgb=bool(modes & {"hs", "xy", "rgb", "rgbw", "rgbww"}),
        supports_temperature="color_temp" in modes,
    )


def demo_lights() -> list[Light]:
    return [
        Light(
            "light.studio",
            "Studio Lamp",
            "mdi:lightbulb",
            True,
            72,
            COLOR_PRESETS[0][1],
            3200,
            2000,
            6500,
            True,
            True,
        ),
        Light(
            "light.desk",
            "Desk Light",
            "mdi:desk-lamp",
            True,
            45,
            COLOR_PRESETS[2][1],
            4000,
            2200,
            6500,
            True,
            True,
        ),
        Light(
            "light.lounge",
            "Lounge",
            "mdi:floor-lamp",
            False,
            0,
            COLOR_PRESETS[3][1],
            2700,
            2000,
            6500,
            True,
            True,
        ),
        Light(
            "light.bedroom",
            "Bedroom",
            "mdi:ceiling-light",
            True,
            28,
            COLOR_PRESETS[1][1],
            2400,
            2000,
            6500,
            False,
            True,
        ),
        Light(
            "light.hall",
            "Hall",
            "mdi:wall-sconce-flat",
            False,
            0,
            COLOR_PRESETS[0][1],
            3500,
            2000,
            6500,
            False,
            True,
        ),
    ]


def nearest_color_index(rgb: tuple[int, int, int]) -> int:
    return min(
        range(len(COLOR_PRESETS)),
        key=lambda index: sum(
            (rgb[channel] - COLOR_PRESETS[index][1][channel]) ** 2
            for channel in range(3)
        ),
    )


def text_element(
    element_id: str,
    text: str,
    x: int,
    y: int,
    *,
    display: types.DisplayName = types.DisplayName.FRONT,
    font: str = "tiny",
    color: str = WHITE,
    **extra: Any,
) -> types.TextElement:
    return types.TextElement(
        id=element_id,
        display=display,
        x=x,
        y=y,
        text=text,
        font=font,
        color=color,
        **extra,
    )


class App:
    def __init__(self, args: argparse.Namespace) -> None:
        entity_ids = [
            value.strip() for value in args.entities.split(",") if value.strip()
        ]
        self.ha = HomeAssistant(args.ha_url, args.ha_token, entity_ids)
        self.demo = not self.ha.configured
        self.lights = demo_lights()
        self.host = args.host
        self.client = AsyncBusyBar(args.host, token=args.busy_token or None)
        self.navigation = args.screen
        self.selected = 0
        self.control = 0
        self.pending: dict[str, tuple[str, dict[str, Any]]] = {}
        self.workers: dict[str, asyncio.Task[None]] = {}

    @property
    def light(self) -> Light:
        self.selected %= len(self.lights)
        return self.lights[self.selected]

    async def load(self) -> None:
        if not self.demo:
            self.lights = await asyncio.to_thread(self.ha.get_lights)
            if not self.lights:
                raise RuntimeError("No Home Assistant lights matched HA_ENTITIES")
        await self.upload_icons()

    async def upload_icons(self) -> None:
        assets = {}
        for icon_name in {light.icon_name for light in self.lights}:
            token = icon_asset_token(icon_name)
            active = icon_color(icon_name)
            assets.update(
                {
                    f"ha_a_{token}.png": icon_png(icon_name, 14, active),
                    f"ha_i_{token}.png": icon_png(icon_name, 14, "#7080A0"),
                    f"ha_b_{token}.png": icon_png(icon_name, 40, "#FFFFFF"),
                }
            )
        assets["ha-blank.png"] = resvg_py.svg_to_bytes(
            svg_string='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>',
            width=1,
            height=1,
        )
        for filename, data in assets.items():
            await self.client.assets_upload(APP, filename, data)

    def browse_window(self) -> tuple[list[Light], int]:
        page_start = self.selected // 4 * 4
        return self.lights[page_start : page_start + 4], self.selected - page_start

    def control_value(self) -> str:
        control = self.light.controls[self.control % len(self.light.controls)]
        if control == "brightness":
            return f"{self.light.brightness}%"
        if control == "color":
            return COLOR_PRESETS[nearest_color_index(self.light.rgb)][0]
        return f"{self.light.temperature}K"

    def payload(self) -> types.DisplayElements:
        light = self.light
        if self.navigation == "browse":
            window, selected_slot = self.browse_window()
            images: list[types.DisplayElement] = []
            for slot in range(4):
                active = slot == selected_slot and slot < len(window)
                images.append(
                    types.ImageElement(
                        id=f"front_image_{slot}",
                        display=types.DisplayName.FRONT,
                        x=slot * 18 + 2,
                        y=1,
                        path=(
                            icon_path(window[slot], "active")
                            if active
                            else icon_path(window[slot], "inactive")
                            if slot < len(window)
                            else "ha-blank.png"
                        ),
                    )
                )
            mode = "DEMO" if self.demo else "LIVE"
            return types.DisplayElements(
                application_name=APP,
                priority=100,
                elements=[
                    *images,
                    types.ImageElement(
                        id="back_icon",
                        display=types.DisplayName.BACK,
                        x=8,
                        y=15,
                        path=icon_path(light, "back"),
                    ),
                    text_element(
                        "back_kicker",
                        f"HOME ASSISTANT  {self.selected + 1}/{len(self.lights)}",
                        58,
                        8,
                        display=types.DisplayName.BACK,
                        color=MUTED,
                    ),
                    text_element(
                        "back_name",
                        light.name,
                        58,
                        25,
                        display=types.DisplayName.BACK,
                        font="normal",
                        width=96,
                        scroll_rate=70,
                        scroll_start_delay=150,
                        scroll_repeat_delay=250,
                    ),
                    text_element(
                        "back_state",
                        f"LIGHT  ·  {'ON' if light.is_on else 'OFF'}  ·  {light.brightness}%",
                        58,
                        47,
                        display=types.DisplayName.BACK,
                        font="small",
                        color=WHITE if light.is_on else OFF,
                    ),
                    text_element(
                        "back_hint",
                        "DIAL: BROWSE   SELECT: CONTROLS   START: TOGGLE",
                        8,
                        68,
                        display=types.DisplayName.BACK,
                        color=MUTED,
                    ),
                    text_element(
                        "back_mode",
                        mode,
                        152,
                        8,
                        display=types.DisplayName.BACK,
                        color=MUTED,
                        align="top_right",
                    ),
                ],
            )

        controls = light.controls
        self.control %= len(controls)
        back_labels = {"brightness": "DIM", "color": "RGB", "temperature": "TEMP"}
        front_labels = {
            "brightness": "BRIGHT",
            "color": "COLOR",
            "temperature": "TEMP",
        }
        selected_control = controls[self.control]
        elements: list[types.DisplayElement] = [
            types.ImageElement(
                id="front_image_0",
                display=types.DisplayName.FRONT,
                x=1,
                y=1,
                path=icon_path(light, "active" if light.is_on else "inactive"),
            ),
            *[
                types.ImageElement(
                    id=f"front_image_{slot}",
                    display=types.DisplayName.FRONT,
                    x=0,
                    y=0,
                    path="ha-blank.png",
                )
                for slot in range(1, 4)
            ],
            text_element(
                "front_text_0",
                front_labels[selected_control],
                18,
                0,
                color=ACCENT,
            ),
            text_element(
                "front_text_1",
                self.control_value(),
                18,
                8,
                font="small",
                color=ACCENT if self.navigation == "edit" else WHITE,
            ),
            text_element(
                "front_text_2",
                f"{self.control + 1}/{len(controls)}" if len(controls) > 1 else "",
                70,
                0,
                color=MUTED,
                align="top_right",
            ),
            text_element(
                "front_text_3",
                "EDIT" if self.navigation == "edit" else "",
                70,
                8,
                color=ACCENT,
                align="top_right",
            ),
        ]
        elements.extend(
            [
                types.ImageElement(
                    id="back_icon",
                    display=types.DisplayName.BACK,
                    x=8,
                    y=15,
                    path=icon_path(light, "back"),
                ),
                text_element(
                    "back_kicker",
                    f"HOME ASSISTANT  {self.selected + 1}/{len(self.lights)}",
                    58,
                    8,
                    display=types.DisplayName.BACK,
                    color=MUTED,
                ),
                text_element(
                    "back_name",
                    light.name,
                    58,
                    25,
                    display=types.DisplayName.BACK,
                    font="normal",
                    width=96,
                    scroll_rate=70,
                    scroll_start_delay=150,
                    scroll_repeat_delay=250,
                ),
                text_element(
                    "back_state",
                    f"{back_labels[selected_control]}  ·  {self.control_value()}",
                    58,
                    47,
                    display=types.DisplayName.BACK,
                    font="small",
                    color=ACCENT,
                ),
                text_element(
                    "back_hint",
                    "DIAL: CHANGE   SELECT: DONE   START: TOGGLE"
                    if self.navigation == "edit"
                    else "DIAL: PICK   SELECT: EDIT   START: TOGGLE",
                    8,
                    68,
                    display=types.DisplayName.BACK,
                    color=MUTED,
                ),
                text_element(
                    "back_mode",
                    self.navigation.upper(),
                    152,
                    8,
                    display=types.DisplayName.BACK,
                    color=MUTED,
                    align="top_right",
                ),
            ]
        )
        return types.DisplayElements(
            application_name=APP, priority=100, elements=elements
        )

    async def draw(self) -> None:
        await self.client.display_draw(self.payload(), sanitize_text=True)

    def queue_service(self, service: str, data: dict[str, Any]) -> None:
        if self.demo:
            return
        entity_id = data["entity_id"]
        self.pending[entity_id] = (service, data)
        worker = self.workers.get(entity_id)
        if not worker or worker.done():
            self.workers[entity_id] = asyncio.create_task(
                self.flush_services(entity_id)
            )

    async def flush_services(self, entity_id: str) -> None:
        while entity_id in self.pending:
            service, data = self.pending.pop(entity_id)
            try:
                await asyncio.to_thread(self.ha.service, service, data)
            except requests.RequestException as error:
                print(f"Home Assistant service failed: {error}")
            await asyncio.sleep(0.04)

    async def toggle(self) -> None:
        self.light.is_on = not self.light.is_on
        self.queue_service(
            "turn_on" if self.light.is_on else "turn_off",
            {"entity_id": self.light.entity_id},
        )
        await self.draw()

    async def adjust(self, delta: int) -> None:
        light = self.light
        control = light.controls[self.control]
        if control == "brightness":
            light.brightness = max(0, min(100, light.brightness + delta * 5))
            light.is_on = light.brightness > 0
            self.queue_service(
                "turn_on",
                {"entity_id": light.entity_id, "brightness_pct": light.brightness},
            )
        elif control == "color":
            index = (nearest_color_index(light.rgb) + delta) % len(COLOR_PRESETS)
            light.rgb = COLOR_PRESETS[index][1]
            light.is_on = True
            self.queue_service(
                "turn_on", {"entity_id": light.entity_id, "rgb_color": list(light.rgb)}
            )
        else:
            light.temperature = max(
                light.min_temperature,
                min(light.max_temperature, light.temperature + delta * 200),
            )
            light.is_on = True
            self.queue_service(
                "turn_on",
                {"entity_id": light.entity_id, "color_temp_kelvin": light.temperature},
            )
        await self.draw()

    async def handle(self, event: str, value: Any) -> None:
        if event == "encoder":
            delta = int(value)
            if self.navigation == "browse":
                self.selected = (self.selected + delta) % len(self.lights)
                self.control = 0
                await self.draw()
            elif self.navigation == "control":
                self.control = (self.control + delta) % len(self.light.controls)
                await self.draw()
            elif self.navigation == "edit":
                await self.adjust(delta)
            return

        button = str(value)
        if button == "start" and self.navigation != "inactive":
            await self.toggle()
        elif button == "ok":
            if self.navigation == "inactive":
                self.navigation = "browse"
            elif self.navigation == "browse":
                self.navigation = "control"
                self.control = 0
            elif self.navigation == "control":
                self.navigation = "edit"
            else:
                self.navigation = "control"
            await self.draw()
        elif button == "back":
            self.navigation = "inactive"
            await self.client.display_clear(application_name=APP)

    async def run(self, once: bool) -> None:
        await self.load()
        await self.draw()
        mode = "demo" if self.demo else "Home Assistant"
        print(f"{APP} → {mode} on {self.host}")
        if once:
            await self.client.aclose()
            return
        try:
            async for message in self.client.stream_status_ws():
                if not isinstance(message, dict):
                    continue
                for event, value in parse_events(message):
                    await self.handle(event, value)
        finally:
            await asyncio.gather(*self.workers.values(), return_exceptions=True)
            if not once:
                try:
                    await self.client.display_clear(application_name=APP)
                except exceptions.BusyBarError:
                    pass
            await self.client.aclose()


def parse_events(message: dict[str, Any]) -> list[tuple[str, Any]]:
    events: list[tuple[str, Any]] = []
    for update in message.get("updates", []):
        input_update = update.get("input", {})
        if "button_event" in input_update:
            button = input_update.get("button_event") or {}
            if str(button.get("action", "")).lower() in ("", "press"):
                events.append(("button", str(button.get("button") or "ok").lower()))
        encoder = input_update.get("encoder_event")
        if encoder and int(encoder.get("delta", 0)):
            events.append(("encoder", int(encoder["delta"])))
    return events


async def main() -> None:
    args = parse_args()
    app = App(args)
    try:
        await app.run(args.once)
    except exceptions.BusyBarError as error:
        raise SystemExit(f"BUSY Bar error: {error}") from error
    except requests.RequestException as error:
        raise SystemExit(f"Home Assistant error: {error}") from error


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\nstopped")
manifest.yaml
name: Home Assistant
author: kylewhirl
description: Browse and control Home Assistant lights with the BUSY Bar dial, Select, and Start controls.
tags:
  - home
  - monitor
  - info
preview: ./preview.png
repo: https://github.com/kylewhirl/busybar-home-assistant
requirements.txt
busylib==1.0.0
material-design-icons-pack==7.4.47
requests>=2.32.0,<3
resvg_py==0.3.3

Source and Provenance

View This Version on GitHub → · Author’s Project →

Source commit: 26b19ba9ce2ba192c45b4c0eadcfad53df42b153
Content SHA-256: 76572b9d276df000831e00ce9e6282d026fd5524f4fc8df9816d529a20984e9b

The Community Workbench

Rate and Discuss

Comments and reactions live in this site’s GitHub repository. Sign in with GitHub to participate.