Add HACS integration, 8 Lovelace cards, and pairing page
New HA integration (integration/custom_components/luma_home/) with: - WS API: luma/get_config (Lovelace→v2 bridge), luma/pair, luma/pair_status - Config flow with dashboard URL path setting 8 HACS frontend cards (cards/) matching TV app visual spec: - toggle, range, climate, media, curtain, action, vacuum, weather - Glass card design: rgba(36,40,48,0.75) bg, 22px radius, accent glows Pairing page (pairing/luma-pair.html) for TV token provisioning. Web dashboard (web-dashboard/) — legacy config editor (deprecated). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
a890ba6baf
commit
d681f20884
18 files changed
+2708
No files matched your search
@@ -0,0 +1,29 @@
|
||||
"""Luma Home — config bridge between HA dashboards and Luma TV app."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from . import websocket_api
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||
"""Set up the Luma Home component."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Luma Home from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
"dashboard_url_path": entry.data.get("dashboard_url_path", "luma-tv"),
|
||||
}
|
||||
websocket_api.async_register_commands(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||
return True
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Config flow for Luma Home."""
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .const import DOMAIN, CONF_DASHBOARD_URL_PATH, DEFAULT_DASHBOARD_URL_PATH
|
||||
|
||||
|
||||
class LumaHomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Luma Home."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title="Luma Home",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_DASHBOARD_URL_PATH,
|
||||
default=DEFAULT_DASHBOARD_URL_PATH,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
return LumaHomeOptionsFlow(config_entry)
|
||||
|
||||
|
||||
class LumaHomeOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Handle options."""
|
||||
|
||||
def __init__(self, config_entry):
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_DASHBOARD_URL_PATH,
|
||||
default=self.config_entry.data.get(
|
||||
CONF_DASHBOARD_URL_PATH, DEFAULT_DASHBOARD_URL_PATH
|
||||
),
|
||||
): str,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
DOMAIN = "luma_home"
|
||||
CONF_DASHBOARD_URL_PATH = "dashboard_url_path"
|
||||
DEFAULT_DASHBOARD_URL_PATH = "luma-tv"
|
||||
PAIRING_DIR = "luma-pairing"
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "luma_home",
|
||||
"name": "Luma Home",
|
||||
"codeowners": ["@jazzymc"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://git.xtrm-lab.org/jazzymc/luma-home",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://git.xtrm-lab.org/jazzymc/luma-home/issues",
|
||||
"requirements": [],
|
||||
"version": "0.1.0"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Luma Home Setup",
|
||||
"description": "Configure the dashboard that drives the Luma TV app.",
|
||||
"data": {
|
||||
"dashboard_url_path": "Dashboard URL path"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Luma Home is already configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"dashboard_url_path": "Dashboard URL path"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"""WebSocket API for Luma Home.
|
||||
|
||||
Exposes:
|
||||
luma/get_config — returns v2 config derived from a Lovelace dashboard
|
||||
luma/pair — writes a pairing file for TV token provisioning
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN, DEFAULT_DASHBOARD_URL_PATH, PAIRING_DIR
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA_VERSION = 2
|
||||
|
||||
LUMA_CARD_TYPE_MAP = {
|
||||
"custom:luma-range-card": "range",
|
||||
"custom:luma-toggle-card": "toggle",
|
||||
"custom:luma-climate-card": "climate",
|
||||
"custom:luma-media-card": "media",
|
||||
"custom:luma-curtain-card": "curtain",
|
||||
"custom:luma-action-card": "action",
|
||||
"custom:luma-vacuum-card": "vacuum",
|
||||
"custom:luma-weather-card": "weather",
|
||||
}
|
||||
|
||||
HA_CARD_TYPE_MAP = {
|
||||
"light": "range",
|
||||
"thermostat": "climate",
|
||||
"media-control": "media",
|
||||
"weather-forecast": "weather",
|
||||
"button": "action",
|
||||
}
|
||||
|
||||
CONTAINER_CARD_TYPES = {
|
||||
"vertical-stack",
|
||||
"horizontal-stack",
|
||||
"grid",
|
||||
"conditional",
|
||||
"entities",
|
||||
"sections",
|
||||
}
|
||||
|
||||
|
||||
def async_register_commands(hass: HomeAssistant) -> None:
|
||||
"""Register WS commands."""
|
||||
websocket_api.async_register_command(hass, handle_get_config)
|
||||
websocket_api.async_register_command(hass, handle_pair)
|
||||
websocket_api.async_register_command(hass, handle_pair_status)
|
||||
|
||||
|
||||
def _get_dashboard_path(hass: HomeAssistant) -> str:
|
||||
"""Get the configured dashboard URL path."""
|
||||
for entry_data in hass.data.get(DOMAIN, {}).values():
|
||||
if isinstance(entry_data, dict):
|
||||
return entry_data.get("dashboard_url_path", DEFAULT_DASHBOARD_URL_PATH)
|
||||
return DEFAULT_DASHBOARD_URL_PATH
|
||||
|
||||
|
||||
def _flatten_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Recursively unwrap container cards to get leaf cards."""
|
||||
result = []
|
||||
for card in cards:
|
||||
card_type = card.get("type", "")
|
||||
if card_type in CONTAINER_CARD_TYPES:
|
||||
children = card.get("cards", [])
|
||||
if card_type == "conditional":
|
||||
children = card.get("card", [])
|
||||
if isinstance(children, dict):
|
||||
children = [children]
|
||||
elif card_type == "entities":
|
||||
# entities card has entity rows, not sub-cards — skip it
|
||||
# unless it wraps actual card children
|
||||
children = card.get("cards", [])
|
||||
result.extend(_flatten_cards(children))
|
||||
else:
|
||||
result.append(card)
|
||||
return result
|
||||
|
||||
|
||||
def _map_card(card: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Map a Lovelace card config to a Luma v2 card config.
|
||||
|
||||
Returns None if the card type is not recognized.
|
||||
"""
|
||||
card_type = card.get("type", "")
|
||||
|
||||
# Custom Luma cards — direct passthrough
|
||||
if card_type in LUMA_CARD_TYPE_MAP:
|
||||
luma_type = LUMA_CARD_TYPE_MAP[card_type]
|
||||
return {
|
||||
"type": luma_type,
|
||||
"entity_id": card.get("entity", card.get("entity_id", "")),
|
||||
"name": card.get("name", ""),
|
||||
"size": card.get("size", "1x1"),
|
||||
"accent_color": card.get("accent_color", ""),
|
||||
"glow_color": card.get("glow_color", ""),
|
||||
"icon": card.get("icon", ""),
|
||||
"tap_action": card.get("tap_action"),
|
||||
"hold_action": card.get("hold_action"),
|
||||
"confirmation": card.get("confirmation", False),
|
||||
}
|
||||
|
||||
# Standard HA cards — best-effort mapping
|
||||
if card_type in HA_CARD_TYPE_MAP:
|
||||
luma_type = HA_CARD_TYPE_MAP[card_type]
|
||||
entity = card.get("entity", "")
|
||||
return {
|
||||
"type": luma_type,
|
||||
"entity_id": entity,
|
||||
"name": card.get("name", ""),
|
||||
"size": "1x1",
|
||||
"accent_color": "",
|
||||
"glow_color": "",
|
||||
"icon": "",
|
||||
"tap_action": card.get("tap_action"),
|
||||
"hold_action": card.get("hold_action"),
|
||||
"confirmation": False,
|
||||
}
|
||||
|
||||
# Standard HA entity-domain based fallback for 'button' cards etc.
|
||||
entity = card.get("entity", "")
|
||||
if entity:
|
||||
domain = entity.split(".")[0] if "." in entity else ""
|
||||
domain_map = {
|
||||
"light": "range",
|
||||
"switch": "toggle",
|
||||
"input_boolean": "toggle",
|
||||
"climate": "climate",
|
||||
"media_player": "media",
|
||||
"cover": "curtain",
|
||||
"scene": "action",
|
||||
"script": "action",
|
||||
"vacuum": "vacuum",
|
||||
"weather": "weather",
|
||||
}
|
||||
if domain in domain_map:
|
||||
return {
|
||||
"type": domain_map[domain],
|
||||
"entity_id": entity,
|
||||
"name": card.get("name", ""),
|
||||
"size": "1x1",
|
||||
"accent_color": "",
|
||||
"glow_color": "",
|
||||
"icon": card.get("icon", ""),
|
||||
"tap_action": card.get("tap_action"),
|
||||
"hold_action": card.get("hold_action"),
|
||||
"confirmation": False,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _map_view(view: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map a Lovelace view to a Luma v2 room."""
|
||||
raw_cards = view.get("cards", [])
|
||||
flat_cards = _flatten_cards(raw_cards)
|
||||
mapped = [c for c in (_map_card(card) for card in flat_cards) if c is not None]
|
||||
|
||||
return {
|
||||
"id": view.get("path", view.get("title", "").lower().replace(" ", "_")),
|
||||
"name": view.get("title", ""),
|
||||
"icon": view.get("icon", ""),
|
||||
"theme": view.get("theme"),
|
||||
"badges": view.get("badges", []),
|
||||
"subview": view.get("subview", False),
|
||||
"cards": mapped,
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "luma/get_config",
|
||||
vol.Optional("dashboard_url_path"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def handle_get_config(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return the Luma v2 config derived from a Lovelace dashboard."""
|
||||
url_path = msg.get("dashboard_url_path") or _get_dashboard_path(hass)
|
||||
|
||||
try:
|
||||
config = await hass.data["lovelace"]["dashboards"][url_path].async_load(False)
|
||||
except KeyError:
|
||||
# Try the default dashboard
|
||||
try:
|
||||
config = await hass.data["lovelace"]["dashboards"][None].async_load(False)
|
||||
_LOGGER.warning(
|
||||
"Dashboard '%s' not found, falling back to default", url_path
|
||||
)
|
||||
except (KeyError, Exception) as err:
|
||||
connection.send_error(msg["id"], "not_found", f"Dashboard not found: {err}")
|
||||
return
|
||||
|
||||
views = config.get("views", [])
|
||||
rooms = [_map_view(v) for v in views]
|
||||
|
||||
result = {
|
||||
"version": CONFIG_SCHEMA_VERSION,
|
||||
"rooms": rooms,
|
||||
}
|
||||
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "luma/pair",
|
||||
vol.Required("pin"): str,
|
||||
vol.Required("token"): str,
|
||||
vol.Optional("ha_url"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def handle_pair(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Write a pairing file for TV token provisioning."""
|
||||
pin = msg["pin"]
|
||||
token = msg["token"]
|
||||
ha_url = msg.get("ha_url", "")
|
||||
|
||||
if not pin.isdigit() or len(pin) != 6:
|
||||
connection.send_error(msg["id"], "invalid_pin", "PIN must be 6 digits")
|
||||
return
|
||||
|
||||
www_dir = Path(hass.config.path("www"))
|
||||
pairing_dir = www_dir / PAIRING_DIR
|
||||
pairing_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
pairing_file = pairing_dir / f"{pin}.json"
|
||||
payload = {"token": token, "ha_url": ha_url, "pin": pin}
|
||||
|
||||
await hass.async_add_executor_job(
|
||||
_write_pairing_file, pairing_file, payload
|
||||
)
|
||||
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
_LOGGER.info("Pairing file written for PIN %s", pin)
|
||||
|
||||
|
||||
def _write_pairing_file(path: Path, data: dict) -> None:
|
||||
"""Write pairing JSON to disk."""
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "luma/pair_status",
|
||||
vol.Required("pin"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def handle_pair_status(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Check if a pairing file exists and return/clean it up.
|
||||
|
||||
Called by the TV after it polls the HTTP endpoint and finds the file.
|
||||
This command lets authenticated clients clean up the pairing file.
|
||||
"""
|
||||
pin = msg["pin"]
|
||||
www_dir = Path(hass.config.path("www"))
|
||||
pairing_file = www_dir / PAIRING_DIR / f"{pin}.json"
|
||||
|
||||
if not await hass.async_add_executor_job(pairing_file.exists):
|
||||
connection.send_result(msg["id"], {"paired": False})
|
||||
return
|
||||
|
||||
data = json.loads(
|
||||
await hass.async_add_executor_job(pairing_file.read_text, "utf-8")
|
||||
)
|
||||
|
||||
# Clean up immediately
|
||||
await hass.async_add_executor_job(pairing_file.unlink, True)
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{"paired": True, "token": data.get("token", ""), "ha_url": data.get("ha_url", "")},
|
||||
)
|
||||
Reference in new issue
Block a user