Developer Guide: Embedded Lovelace Card in a Home Assistant Integration

Hi,

I’ve made this guide after struggling to find how to serve a custom card from an integration as it was not documented.

This way

  • card always stays in sync with the integration
  • better user experience, no need to install separately card & integration
  • card is automatically registered and resource added to lovelace

Provided snippets are cleaned code from this integration

This guide explains how to automatically embed a custom Lovelace card within a Home Assistant integration, without requiring users to manually add the JavaScript resource.

Overview

The mechanism relies on three pillars:

  1. Dependency declaration in manifest.json
  2. Static HTTP path registration to serve JavaScript files
  3. Automatic resource addition to Lovelace resources (storage mode)

Step 1: Directory Structure

custom_components/
└── your_integration/
    ├── __init__.py              # Integration entry point
    ├── manifest.json            # Integration metadata
    ├── const.py                 # Constants (URL_BASE, JSMODULES)
    ├── services.yaml            # Service definitions (optional)
    └── frontend/
        ├── __init__.py          # JSModuleRegistration
        └── your-card.js         # Compiled JavaScript card file

Step 2: Configure manifest.json

The manifest.json must declare frontend and http dependencies:

{
  "domain": "your_integration",
  "name": "Your Integration",
  "version": "1.0.0",
  "dependencies": [
    "frontend",
    "http"
  ],
  "config_flow": true,
  "iot_class": "cloud_polling"
}

:exclamation: Important
Without the frontend and http dependencies, resource registration will fail.


Step 3: Define Constants

In const.py, define the base URL and JavaScript modules list:

from pathlib import Path
import json
from typing import Final

# Read version from manifest.json
MANIFEST_PATH = Path(__file__).parent / "manifest.json"
with open(MANIFEST_PATH, encoding="utf-8") as f:
    INTEGRATION_VERSION: Final[str] = json.load(f).get("version", "0.0.0")

DOMAIN: Final[str] = "your_integration"

# Base URL for frontend resources
URL_BASE: Final[str] = "/your-integration"

# List of JavaScript modules to register
JSMODULES: Final[list[dict[str, str]]] = [
    {
        "name": "Your Card",
        "filename": "your-card.js",
        "version": INTEGRATION_VERSION,
    },
    # Add editor if needed
    {
        "name": "Your Card Editor",
        "filename": "your-card-editor.js",
        "version": INTEGRATION_VERSION,
    },
]

Step 4: Create JSModuleRegistration Class

Create frontend/__init__.py with the registration class:

"""JavaScript module registration."""

import logging
from pathlib import Path
from typing import Any

from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant
from homeassistant.helpers.event import async_call_later

from ..const import JSMODULES, URL_BASE, INTEGRATION_VERSION

_LOGGER = logging.getLogger(__name__)


class JSModuleRegistration:
    """Registers JavaScript modules in Home Assistant."""

    def __init__(self, hass: HomeAssistant) -> None:
        """Initialize the registrar."""
        self.hass = hass
        self.lovelace = self.hass.data.get("lovelace")

    async def async_register(self) -> None:
        """Register frontend resources."""
        await self._async_register_path()
        # Only register modules if Lovelace is in storage mode
        if self.lovelace.mode == "storage":
            await self._async_wait_for_lovelace_resources()

    async def _async_register_path(self) -> None:
        """Register the static HTTP path."""
        try:
            await self.hass.http.async_register_static_paths(
                [StaticPathConfig(URL_BASE, Path(__file__).parent, False)]
            )
            _LOGGER.debug("Path registered: %s -> %s", URL_BASE, Path(__file__).parent)
        except RuntimeError:
            _LOGGER.debug("Path already registered: %s", URL_BASE)

    async def _async_wait_for_lovelace_resources(self) -> None:
        """Wait for Lovelace resources to load."""

        async def _check_loaded(_now: Any) -> None:
            if self.lovelace.resources.loaded:
                await self._async_register_modules()
            else:
                _LOGGER.debug("Lovelace resources not loaded, retrying in 5s")
                async_call_later(self.hass, 5, _check_loaded)

        await _check_loaded(0)

    async def _async_register_modules(self) -> None:
        """Register or update JavaScript modules."""
        _LOGGER.debug("Installing JavaScript modules")

        # Get existing resources from this integration
        existing_resources = [
            r for r in self.lovelace.resources.async_items()
            if r["url"].startswith(URL_BASE)
        ]

        for module in JSMODULES:
            url = f"{URL_BASE}/{module['filename']}"
            registered = False

            for resource in existing_resources:
                if self._get_path(resource["url"]) == url:
                    registered = True
                    # Check if update needed
                    if self._get_version(resource["url"]) != module["version"]:
                        _LOGGER.info(
                            "Updating %s to version %s",
                            module["name"], module["version"]
                        )
                        await self.lovelace.resources.async_update_item(
                            resource["id"],
                            {
                                "res_type": "module",
                                "url": f"{url}?v={module['version']}",
                            },
                        )
                    break

            if not registered:
                _LOGGER.info(
                    "Registering %s version %s",
                    module["name"], module["version"]
                )
                await self.lovelace.resources.async_create_item(
                    {
                        "res_type": "module",
                        "url": f"{url}?v={module['version']}",
                    }
                )

    def _get_path(self, url: str) -> str:
        """Extract path without parameters."""
        return url.split("?")[0]

    def _get_version(self, url: str) -> str:
        """Extract version from URL."""
        parts = url.split("?")
        if len(parts) > 1 and parts[1].startswith("v="):
            return parts[1].replace("v=", "")
        return "0"

    async def async_unregister(self) -> None:
        """Remove Lovelace resources from this integration."""
        if self.lovelace.mode == "storage":
            for module in JSMODULES:
                url = f"{URL_BASE}/{module['filename']}"
                resources = [
                    r for r in self.lovelace.resources.async_items()
                    if r["url"].startswith(url)
                ]
                for resource in resources:
                    await self.lovelace.resources.async_delete_item(resource["id"])

Step 5: Call Registration in async_setup

In the main __init__.py, call JSModuleRegistration:

from homeassistant.core import (
    HomeAssistant,
    CoreState,
    EVENT_HOMEASSISTANT_STARTED,
)
from homeassistant.components import websocket_api
from homeassistant.helpers import config_validation as cv
import voluptuous as vol

from .frontend import JSModuleRegistration
from .const import DOMAIN, INTEGRATION_VERSION


async def async_register_frontend(hass: HomeAssistant) -> None:
    """Register frontend modules after HA startup."""
    module_register = JSModuleRegistration(hass)
    await module_register.async_register()


@websocket_api.websocket_command(
    {
        vol.Required("type"): f"{DOMAIN}/version",
    }
)
@websocket_api.async_response
async def websocket_get_version(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict,
) -> None:
    """Handle version request from frontend."""
    connection.send_result(
        msg["id"],
        {"version": INTEGRATION_VERSION},
    )


async def async_setup(hass: HomeAssistant, config: dict) -> bool:
    """Set up the component."""

    # Register websocket command for version checking
    websocket_api.async_register_command(hass, websocket_get_version)

    async def _setup_frontend(_event=None) -> None:
        await async_register_frontend(hass)

    # If HA is already running, register immediately
    if hass.state == CoreState.running:
        await _setup_frontend()
    else:
        # Otherwise, wait for STARTED event
        hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _setup_frontend)

    return True

:warning: WARNING
Registration must happen in async_setup, not async_setup_entry.
This ensures registration occurs once per integration, not per config entry.


Step 6: Create the JavaScript Card

The card must register as a custom element and declare itself in window.customCards:

import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

// Version embedded during build (replace with your actual version)
const CARD_VERSION = '1.0.0';

@customElement('your-card')
export class YourCard extends LitElement {
  @property({ attribute: false }) hass;
  @property({ attribute: false }) config;
  
  private backendVersion = null;
  private versionCheckDone = false;

  static getConfigElement() {
    return document.createElement('your-card-editor');
  }

  static getStubConfig() {
    return { entity: '' };
  }

  setConfig(config) {
    this.config = config;
  }

  getCardSize() {
    return 3;
  }

  connectedCallback() {
    super.connectedCallback();
    this.checkVersion();
  }

  async checkVersion() {
    if (this.versionCheckDone) return;
    
    try {
      const result = await this.hass.connection.sendMessagePromise({
        type: 'your_integration/version',
      });
      
      this.backendVersion = result.version;
      this.versionCheckDone = true;
      
      if (this.backendVersion !== CARD_VERSION) {
        this.showVersionMismatch();
      }
    } catch (err) {
      console.error('Failed to check version:', err);
    }
  }

  showVersionMismatch() {
    // Show toast notification with reload action
    // Using hass-notification event instead of persistent_notification
    // because toast only appears in current session and gets immediate attention
    const message = `Your Integration version mismatch detected! Backend: ${this.backendVersion} | Frontend: ${CARD_VERSION}`;
    
    this.dispatchEvent(
      new CustomEvent('hass-notification', {
        detail: {
          message: message,
          duration: -1,  // Persistent until dismissed
          dismissable: true,
          action: {
            text: 'Reload',
            action: this.handleReload,
          },
        },
        bubbles: true,
        composed: true,
      })
    );
  }

  // Arrow function to preserve 'this' context when used as action handler
  handleReload = () => {
    // Clear application cache before reload
    if ('caches' in window) {
      caches.keys().then((names) => {
        names.forEach((name) => {
          caches.delete(name);
        });
      }).then(() => {
        window.location.reload();
      });
    } else {
      window.location.reload();
    }
  }

  render() {
    if (!this.hass || !this.config) {
      return html``;
    }
    return html`<ha-card header="My Card">...</ha-card>`;
  }

  static styles = css`
    ha-card { padding: 16px; }
  `;
}

// Registration to appear in card picker
window.customCards = window.customCards || [];
window.customCards.push({
  type: 'your-card',
  name: 'Your Card',
  preview: true,
  description: 'Description of your card',
});

Step 7: Build and Deployment

Webpack Configuration (example)

// webpack.config.cjs
const path = require('path');
const webpack = require('webpack');
const package = require('./package.json');

module.exports = {
  mode: 'production',
  entry: {
    'your-card': './src/your-card.ts',
    'your-card-editor': './src/your-card-editor.ts',
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, '../custom_components/your_integration/frontend'),
    clean: {
      keep: /__init__\.py$/,  // Keep the Python file
    },
  },
  resolve: {
    extensions: ['.js', '.ts'],
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        use: 'ts-loader',
      },
    ],
  },
  plugins: [
    // Inject version at build time
    new webpack.DefinePlugin({
      CARD_VERSION: JSON.stringify(package.version),
    }),
  ],
};

Step 8: Optional Reload Service

For better user experience, you can add a service to clear cache and reload. Create services.yaml:

reload_frontend:
  name: Reload Frontend
  description: Clears application cache and reloads the page
  fields: {}

And add the service handler in __init__.py:

from homeassistant.helpers import service

async def async_reload_frontend(call):
    """Handle reload frontend service call."""
    # This service is mainly documented for users
    # The actual reload happens on frontend side
    _LOGGER.info("Frontend reload requested via service")

async def async_setup(hass: HomeAssistant, config: dict) -> bool:
    """Set up the component."""
    
    # ... previous code ...
    
    # Register reload service
    hass.services.async_register(
        DOMAIN,
        "reload_frontend",
        async_reload_frontend,
    )
    
    return True

Version Management and Application Cache

The Application Cache Problem

:warning: CRITICAL ISSUE
The ?v=X.X.X parameter in resource URLs does NOT solve all caching issues!

Why versioned URLs are not enough:

JavaScript module URLs are embedded in cached page files (application cache). When you update your integration:

  1. The backend updates the resource URL with a new version
  2. BUT the cached page still references the OLD URL
  3. Users get version mismatches depending on their start page

Where this manifests:

  • Desktop browsers: Hard refresh (Ctrl+Shift+R) clears cache, masking the issue
  • Companion Apps (iOS/Android): Cache persists, causing consistent problems
  • Different start pages: Some pages cached, others not, leading to inconsistent behavior

Recommended Solution: Version Checking

Implement a version check system inspired by Browser Mod:

  1. Backend exposes version via WebSocket command
  2. Frontend checks version on card load
  3. Mismatch detection triggers user notification
  4. Clear cache + reload button provided to user

Key benefits:

  • Users are informed of version mismatches
  • One-click resolution (clear cache + reload)
  • Works reliably on all platforms
  • Prevents mysterious bugs and support issues

Implementation Checklist

  • Backend WebSocket command returns integration version
  • Frontend card checks version on connectedCallback()
  • Version mismatch shows toast notification (hass-notification event)
  • Reload button clears caches API before reload
  • Version constant embedded during build (webpack DefinePlugin)
  • Optional: Reload service for power users

Summary of Key Elements

Element File Purpose
Dependencies manifest.json "dependencies": ["frontend", "http"]
Base URL const.py Defines /your-integration
Module list const.py JSMODULES with name, filename, version
Version export const.py INTEGRATION_VERSION for WebSocket
Static path frontend/__init__.py async_register_static_paths
Lovelace resource frontend/__init__.py lovelace.resources.async_create_item
WebSocket handler __init__.py Version checking endpoint
Trigger __init__.py In async_setup, listen for EVENT_HOMEASSISTANT_STARTED
Custom Element your-card.js @customElement('your-card')
Version check your-card.js checkVersion() on card load
Cache clear your-card.js handleReload() clears caches API
Card declaration your-card.js window.customCards.push({...})

YAML Mode vs Storage Mode

[!NOTE]
Automatic resource registration only works in storage mode (Lovelace’s default mode).

In YAML mode, users must manually add the resource in ui-lovelace.yaml:

resources:
  - url: /your-integration/your-card.js
    type: module

The static path (/your-integration/your-card.js) is registered in all cases, so the file is always accessible.


Troubleshooting

Users report “card not working after update”

Cause: Application cache contains old module URL

Solution:

  1. Check if version mismatch notification appears
  2. Click reload button in notification
  3. If notification doesn’t appear, manually clear cache:
    • Desktop: Hard refresh (Ctrl+Shift+R / Cmd+Shift+R)
    • Mobile app: Clear app cache or force-stop app

Version check not working

Verify:

  1. WebSocket command registered in async_setup
  2. Frontend calls correct command type (your_integration/version)
  3. Version constant matches between backend and build output
  4. Check browser console for errors

Cache not clearing on reload

Issue: caches API requires HTTPS or localhost

Workaround: Add fallback to standard reload if caches unavailable


Unregistration (Optional)

To clean up resources when uninstalling the integration, call async_unregister in an appropriate hook (if available in your flow).


Credits

2 Likes

Great write up.

From my experience with Browser Mod, I will note that JS module version management can be tricky due to module URLs being part of pages cached in application cache. i.e. the first page that is browsed in a session will be cached, and your module URL will be in that cached page file. It will not be updated like resource scripts. So when you have an updated integration, users may get strange issues depending on whether they start session at a page that is in the application cache or not.

For Browser Mod this is managed by the backend sending version information to frontend, and if there is a mismatch, then a toast message is presented with a warning with the respective versions and a reload button. See hass-browser_mod/js/plugin/version.ts at cf3db7c621cd9d06c08c29d65488c50107a11d1f · thomasloven/hass-browser_mod · GitHub. This uses a Browser Mod notification service but that is just a wrapper for HA toast notification.

For the ‘refresh’ you need to clear the application cache. See here for what Browser Mod has in its refresh service.

Usually this issue is tricky to understand and debug as a developer, as a hard refresh on a desktop Browser will workaround. But it is a stubborn issue on Companion Apps. I went through a lot of debugging with users on this and what I describe above is the outcome which is now flawless with no version issues reported ongoing. e.g. I use Browser Mod in production on my Companion App (iPhone) and when I start a session at a page that has not been a start page for a while, I will still get the Browser Mod version mismatch notification.

1 Like

ho thanks @dcapslock that’s quiet interesting. I’ve updated the guide and code snippets taking in count your advice.

What I’ve added:

  • A whole section about the application cache problem and why ?v=X.X.X isn’t enough
  • WebSocket command on backend to expose version + frontend check on card load
  • Version mismatch notification with a reload button that clears cache before reloading
  • Troubleshooting section for common cache issues

Basically followed the Browser Mod approach you described - backend sends version, frontend compares, and if mismatch shows a toast with reload button that clears the caches API.

Can you tell me what you think about it ? I still need to implement that on my integration now. :slight_smile:

Wow. Great generic implementation of version code. Nicely done. One suggestion on showVersionMismatch()

Here I suggest a Toast rather than Notification in sidebar as the later would be confusing as it is shown for all HA sessions, whereas a Toast is just shown for the current session and will get immediate attention.

Try something like…

const message = `Your Integration version mismatch detected! Backend: ${this.backendVersion} | Frontend: ${CARD_VERSION}`;

this.dispatchEvent(new CustomEvent("hass-notification", { detail: { message: message, duration: -1, dismissable: true, action: { text: "Reload", action: this.handleReload } }, bubbles: true, composed: true }));

1 Like

yes better :+1:
I’ve updated the snippets.

Thanks

1 Like