diff --git a/.gitignore b/.gitignore index acf9c60..96a0e2b 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,7 @@ yarn-error.log* lerna-debug.log* # Misc -.env.sentry-build-plugin \ No newline at end of file +.env.sentry-build-plugin + +# Claude settings +.claude/settings.local.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 6cfb7f5..ee797c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,41 +122,55 @@ When creating a new epic with sub-issues: ## Development Workflow -### Home Assistant Development Setup +### Home Assistant Integration -For developing with Home Assistant integration: +Liebe runs as a web application that integrates with Home Assistant via custom panel. -```bash -npm run dev -``` +#### Development Setup + +1. **Start the development server**: + + ```bash + npm install + npm run dev + ``` -Add to Home Assistant configuration.yaml: +2. **Add to Home Assistant configuration.yaml**: + + ```yaml + panel_custom: + - name: liebe-panel + sidebar_title: Liebe Dev + sidebar_icon: mdi:heart + url_path: liebe + module_url: http://localhost:3000/panel.js + ``` + +3. **Restart Home Assistant** and find "Liebe Dev" in the sidebar. + +#### Production Deployment + +Host Liebe on any web server: ```yaml panel_custom: - - name: liebe-dashboard-dev - sidebar_title: Liebe Dev - sidebar_icon: mdi:react - url_path: liebe-dev - module_url: http://localhost:3000/dev-entry.js + - name: liebe-panel + sidebar_title: Liebe + sidebar_icon: mdi:heart + url_path: liebe + module_url: https://your-server.com/liebe/panel.js ``` -This gives you: - -- Hot module replacement -- Full hass object access (via postMessage bridge) -- Real-time updates as you code - Note: The custom element name in panel_custom must match the name in customElements.define() ### Starting a New Task 1. **Select Task from GitHub Project** - ```bash - gh issue list --assignee @me - gh issue view - ``` +```bash +gh issue list --assignee @me +gh issue view +``` 2. **Create Feature Branch** @@ -199,9 +213,8 @@ Note: The custom element name in panel_custom must match the name in customEleme ``` 3. **Home Assistant Integration Testing** - - Build the custom panel: `npm run build:ha` - - Copy built files to HA config: `cp -r dist/liebe-dashboard /config/www/` - - Update `configuration.yaml` with panel config + - Ensure dev server is running: `npm run dev` + - Update `configuration.yaml` with localhost:3000 URL - Restart Home Assistant to test ### Completing a Task @@ -290,63 +303,31 @@ Note: The custom element name in panel_custom must match the name in customEleme ### Home Assistant Custom Panel -#### Important: panel_iframe is Deprecated - -Home Assistant has deprecated `panel_iframe` in favor of custom panels. Always use `panel_custom` for proper integration with full access to the `hass` object. +#### Custom Panel Integration -**Migration Guide:** If you're coming from `panel_iframe`, see [MIGRATION-FROM-IFRAME.md](/workspace/docs/MIGRATION-FROM-IFRAME.md) for detailed migration steps. +Home Assistant custom panels provide full access to the `hass` object and proper integration with the Home Assistant frontend. Always use `panel_custom` for dashboard integration. #### Development Approaches -**1. Development Custom Panel (Recommended)** - -This approach provides full hass access during development: - -```yaml -# configuration.yaml -panel_custom: - - name: liebe-dashboard-dev - sidebar_title: Liebe Dev - sidebar_icon: mdi:react - url_path: liebe-dev - module_url: /local/liebe-dashboard-dev/custom-panel.js - config: - # Optional: Enable development mode - dev_mode: true - dev_url: 'http://localhost:3000' -``` - -Then use watch mode: - -```bash -./scripts/dev-ha.sh watch --ha-config /path/to/ha/config -``` - -**2. Mock Server for Local Development** +**1. Local Development with Vite** For UI development without Home Assistant: ```bash -# Start mock HA server -./scripts/dev-ha.sh mock-server - -# In another terminal, start dev server npm run dev ``` -**3. Browser Extension for CORS** +This starts a local development server with hot module replacement. You can develop the UI components without needing Home Assistant. -If you need to bypass CORS during development: +**2. Integration Testing with Home Assistant** -1. Install a CORS extension (e.g., "CORS Unblock") -2. Configure it to allow HA → localhost:3000 -3. Use the custom panel configuration +For testing the integration, ensure your dev server is running (`npm run dev`) and that Home Assistant is configured to use `http://localhost:3000/panel.js`. #### Panel Registration ```javascript customElements.define( - 'liebe-dashboard-panel', + 'liebe', class extends HTMLElement { set hass(hass) { // Store hass object for API access @@ -375,13 +356,15 @@ this._hass.callService('light', 'turn_on', { #### Production Configuration +For production, host Liebe on your server: + ```yaml panel_custom: - - name: liebe-dashboard-panel - sidebar_title: Liebe Dashboard - sidebar_icon: mdi:view-dashboard + - name: liebe + sidebar_title: Liebe + sidebar_icon: mdi:heart url_path: liebe - module_url: /local/liebe-dashboard/custom-panel.js + module_url: https://your-server.com/liebe/panel.js config: # Any custom configuration theme: default @@ -467,37 +450,33 @@ try { - Panel not loading: Check module_url path - No hass object: Ensure proper custom element setup - State not updating: Check event subscriptions - - CORS errors: Use custom panel instead of iframe - - Dev server not accessible: Check firewall/network settings + - CORS errors: Ensure proper module_url path in configuration + - Build not updating: Clear browser cache or use hard reload -4. **Development Mode Issues** - - If using panel_iframe (deprecated): No hass object access - - Solution: Always use panel_custom for development - - For hot reload: Use watch build + symlink approach +4. **Development Tips** + - Use symlinks to avoid copying files during development + - Run build in watch mode for faster iteration + - Check browser console for module loading errors ## Development Best Practices ### Modern Home Assistant Development -1. **Never use panel_iframe** - It's deprecated and doesn't provide hass object access -2. **Always use panel_custom** for proper integration -3. **Development workflow options:** - - Watch build with symlinks (recommended) - - Mock server for UI-only development - - Home Assistant dev container for full integration testing +1. **Always use panel_custom** for proper integration with full hass object access +2. **Development workflow:** + - Run `npm run dev` for development with hot reload + - Configure Home Assistant to use `http://localhost:3000/panel.js` + - For production, deploy to a web server and update the URL ### Quick Development Setup ```bash -# One-time setup -./scripts/dev-ha.sh setup - -# For development with real HA -./scripts/dev-ha.sh watch --ha-config /path/to/ha +# Start development server +npm install +npm run dev -# For UI-only development -./scripts/dev-ha.sh mock-server -npm run dev # In another terminal +# Configure Home Assistant to use http://localhost:3000/panel.js +# Restart Home Assistant ``` ## Resources diff --git a/README.md b/README.md index d8c25c0..60eccb0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Liebe Dashboard +# Liebe -A custom Home Assistant dashboard built with TanStack Start and React in SPA mode. +A custom Home Assistant panel built with TanStack Start and React in SPA mode. ## Features @@ -20,57 +20,55 @@ npm run dev # Open http://localhost:3000 ``` -## Building for Home Assistant +Then add to your Home Assistant `configuration.yaml`: -```bash -# Build the custom panel -npm run build:ha - -# Copy to Home Assistant -cp -r dist/liebe-dashboard /config/www/ +```yaml +panel_custom: + - name: liebe-panel + sidebar_title: Liebe Dev + sidebar_icon: mdi:heart + url_path: liebe + module_url: http://localhost:3000/panel.js ``` -Add to your `configuration.yaml`: +## Production + +Host Liebe on any web server and add to your `configuration.yaml`: ```yaml panel_custom: - - name: liebe-dashboard-panel - sidebar_title: Liebe Dashboard - sidebar_icon: mdi:view-dashboard + - name: liebe-panel + sidebar_title: Liebe + sidebar_icon: mdi:heart url_path: liebe - module_url: /local/liebe-dashboard/custom-panel.js + module_url: https://your-server.com/liebe/panel.js ``` -Restart Home Assistant and find "Liebe Dashboard" in the sidebar. +Restart Home Assistant and find "Liebe" in the sidebar. -## Development with Home Assistant +## Testing with Automation Tools -Start the development server: - -```bash -npm run dev -``` - -Add to your Home Assistant `configuration.yaml`: +When testing Liebe with automation tools like Playwright, you may encounter authentication issues. To bypass authentication for testing purposes, you can configure Home Assistant to use trusted networks: ```yaml -panel_custom: - - name: liebe-dashboard-dev - sidebar_title: Liebe Dev - sidebar_icon: mdi:react - url_path: liebe-dev - module_url: http://localhost:3000/dev-entry.js +homeassistant: + auth_providers: + - type: trusted_networks + trusted_networks: + - 192.168.1.100/32 # Replace with your testing machine's IP + trusted_users: + 192.168.1.100: # Replace with your testing machine's IP + - user_id_here # Replace with the user ID to auto-login as + allow_bypass_login: true + - type: homeassistant ``` -**Restart Home Assistant** (required after adding/changing panel_custom). - -This loads a wrapper that embeds your dev server in an iframe while providing access to the hass object via postMessage. +**⚠️ Security Warning**: Only use trusted networks in secure, controlled environments. This bypasses authentication for specified IP addresses. ## Scripts - `npm run dev` - Start development server - `npm run build` - Build SPA application -- `npm run build:ha` - Build custom panel for Home Assistant - `npm run typecheck` - Run TypeScript type checking - `npm run lint` - Run ESLint - `npm run format` - Format code with Prettier diff --git a/package.json b/package.json index 43a84b7..3030c61 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "liebe-dashboard", + "name": "liebe", "private": true, "sideEffects": false, "type": "module", diff --git a/public/dev-entry.js b/public/dev-entry.js deleted file mode 100644 index 5902615..0000000 --- a/public/dev-entry.js +++ /dev/null @@ -1,87 +0,0 @@ -// Minimal custom panel that loads the Vite app in an iframe -// but provides access to the hass object via postMessage - -class LiebeDashboardDevPanel extends HTMLElement { - set hass(hass) { - this._hass = hass; - if (this.iframe) { - this.sendHassUpdate(); - } - } - - connectedCallback() { - // Extract the route from the current URL - const pathParts = window.location.pathname.split('/'); - const liebeIndex = pathParts.findIndex(part => part === 'liebe-dev'); - let iframeSrc = 'http://localhost:3000'; - - if (liebeIndex >= 0) { - // Extract the route part after liebe-dev - const routeParts = pathParts.slice(liebeIndex + 1); - if (routeParts.length > 0) { - const route = '/' + routeParts.join('/'); - // Append the route to the iframe src - iframeSrc = 'http://localhost:3000' + route; - } - } - - this.innerHTML = ``; - this.iframe = this.querySelector('iframe'); - - // Send hass updates when iframe loads - this.iframe.addEventListener('load', () => this.sendHassUpdate()); - - // Listen for messages from iframe - window.addEventListener('message', (e) => { - if (e.origin !== 'http://localhost:3000') return; - - if (e.data.type === 'call-service' && this._hass) { - this._hass.callService(e.data.domain, e.data.service, e.data.serviceData); - } else if (e.data.type === 'route-change') { - // Handle route changes from the iframe - const pathParts = window.location.pathname.split('/'); - const liebeIndex = pathParts.findIndex(part => part === 'liebe-dev'); - if (liebeIndex >= 0) { - // Keep everything up to and including 'liebe-dev' - const basePath = pathParts.slice(0, liebeIndex + 1).join('/'); - const newPath = basePath + e.data.path; - history.pushState(null, '', newPath); - } - } else if (e.data.type === 'get-route') { - // Iframe is asking for current route - const pathParts = window.location.pathname.split('/'); - const liebeIndex = pathParts.findIndex(part => part === 'liebe-dev'); - if (liebeIndex >= 0) { - // Extract the route part after liebe-dev - const routeParts = pathParts.slice(liebeIndex + 1); - const route = routeParts.length > 0 ? '/' + routeParts.join('/') : '/'; - this.iframe.contentWindow.postMessage({ - type: 'current-route', - path: route - }, 'http://localhost:3000'); - } - } - }); - } - - sendHassUpdate() { - if (this._hass && this.iframe && this.iframe.contentWindow) { - try { - this.iframe.contentWindow.postMessage({ - type: 'hass-update', - hass: { - states: this._hass.states, - user: this._hass.user, - config: this._hass.config, - themes: this._hass.themes, - language: this._hass.language - } - }, 'http://localhost:3000'); - } catch (e) { - // Ignore CORS errors during initial load - } - } - } -} - -customElements.define('liebe-dashboard-dev', LiebeDashboardDevPanel); \ No newline at end of file diff --git a/public/panel.js b/public/panel.js new file mode 100644 index 0000000..faef39d --- /dev/null +++ b/public/panel.js @@ -0,0 +1,208 @@ +// Bridge for Home Assistant custom panel integration +// This file allows running Liebe from any external URL (localhost or remote server) + +class LiebePanel extends HTMLElement { + constructor() { + super() + this._hass = null + this.iframe = null + this.stateChangeUnsubscribe = null + + // Extract initial route from current URL + const pathParts = window.location.pathname.split('/') + if (pathParts.length > 2) { + // Remove the first part (empty) and second part (liebe) + this._currentRoute = '/' + pathParts.slice(2).join('/') + } else { + this._currentRoute = '/' + } + } + + set hass(hass) { + this._hass = hass + if (!this.iframe) { + this.render() + } else { + // Only send if iframe already exists + this.sendHassToIframe() + } + + // Subscribe to state changes + this.subscribeToStateChanges() + } + + set panel(panel) { + this._panel = panel + } + + set route(route) { + this._route = route + if (this.iframe && this.iframe.contentWindow) { + try { + this.iframe.contentWindow.postMessage( + { type: 'navigate-to', path: route }, + '*' + ) + } catch (error) { + console.error('Failed to send route to iframe:', error) + } + } + } + + connectedCallback() { + this.render() + } + + render() { + this.innerHTML = '' + + // Create iframe that loads the app from the correct origin + this.iframe = document.createElement('iframe') + // Get the script's src to determine where we're hosted + const currentScript = document.currentScript || document.querySelector('script[src*="panel.js"]') + const scriptUrl = new URL(currentScript.src) + // Use the script's origin as the iframe source + this.iframe.src = scriptUrl.origin + this.iframe.style.width = '100%' + this.iframe.style.height = '100%' + this.iframe.style.border = 'none' + this.iframe.style.display = 'block' + + this.appendChild(this.iframe) + + // Listen for messages from iframe + window.addEventListener('message', this.handleMessage.bind(this)) + + // Send initial hass object once iframe loads + this.iframe.addEventListener('load', () => { + this.sendHassToIframe() + + // The iframe app will request the route when it's ready + }) + } + + sendHassToIframe() { + if (this.iframe && this.iframe.contentWindow && this._hass) { + try { + // Create a serializable version of hass + const hassData = { + states: this._hass.states, + services: this._hass.services, + config: this._hass.config, + user: this._hass.user, + panels: this._hass.panels, + language: this._hass.language, + selectedLanguage: this._hass.selectedLanguage, + themes: this._hass.themes, + selectedTheme: this._hass.selectedTheme, + // Include connection info + auth: { + data: { + hassUrl: this._hass.auth.data.hassUrl + } + } + } + + this.iframe.contentWindow.postMessage( + { type: 'hass-update', hass: hassData }, + '*' + ) + } catch (error) { + console.error('Failed to send hass to iframe:', error) + } + } + } + + handleMessage(event) { + // Only accept messages from our iframe + if (event.source !== this.iframe.contentWindow) return + + if (event.data.type === 'call-service') { + const { domain, service, serviceData } = event.data + this._hass.callService(domain, service, serviceData) + .then(() => { + event.source.postMessage( + { type: 'service-response', success: true, id: event.data.id }, + '*' + ) + }) + .catch((error) => { + event.source.postMessage( + { type: 'service-response', success: false, error: error.message, id: event.data.id }, + '*' + ) + }) + } else if (event.data.type === 'get-route') { + // Iframe app is requesting the current route + event.source.postMessage( + { type: 'navigate-to', path: this._currentRoute }, + '*' + ) + } else if (event.data.type === 'route-change') { + // Handle route changes within the panel + const path = event.data.path + if (path) { + // Store the sub-route in the panel's state + this._currentRoute = path + + // Update URL without triggering Home Assistant navigation + // Get the base path (should be /liebe) + const pathParts = window.location.pathname.split('/') + const basePath = '/' + pathParts[1] + + // Create the new path + const newPath = path === '/' ? basePath : basePath + path + + // Use replaceState to avoid adding to history + history.replaceState({ panelRoute: path }, '', newPath) + + // Don't dispatch location-changed event for sub-routes + // This prevents Home Assistant from trying to load non-existent panels + } + } + } + + subscribeToStateChanges() { + // Unsubscribe from previous subscription if any + if (this.stateChangeUnsubscribe && typeof this.stateChangeUnsubscribe === 'function') { + this.stateChangeUnsubscribe() + this.stateChangeUnsubscribe = null + } + + // Subscribe to state changes if we have a connection + if (this._hass && this._hass.connection) { + try { + this.stateChangeUnsubscribe = this._hass.connection.subscribeEvents( + (event) => { + // Forward state change events to iframe + if (this.iframe && this.iframe.contentWindow) { + try { + this.iframe.contentWindow.postMessage( + { type: 'state-changed', event }, + '*' + ) + } catch (error) { + console.error('Failed to forward state change:', error) + } + } + }, + 'state_changed' + ) + } catch (error) { + console.error('Failed to subscribe to state changes:', error) + } + } + } + + disconnectedCallback() { + window.removeEventListener('message', this.handleMessage.bind(this)) + + // Unsubscribe from state changes + if (this.stateChangeUnsubscribe && typeof this.stateChangeUnsubscribe === 'function') { + this.stateChangeUnsubscribe() + this.stateChangeUnsubscribe = null + } + } +} + +customElements.define('liebe-panel', LiebePanel) \ No newline at end of file diff --git a/src/components/ConfigurationMenu.tsx b/src/components/ConfigurationMenu.tsx index cc7ac13..0f3b657 100644 --- a/src/components/ConfigurationMenu.tsx +++ b/src/components/ConfigurationMenu.tsx @@ -37,7 +37,7 @@ export function ConfigurationMenu() { const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url - link.download = `liebe-dashboard-${new Date().toISOString().split('T')[0]}.yaml` + link.download = `liebe-${new Date().toISOString().split('T')[0]}.yaml` link.click() URL.revokeObjectURL(url) } catch (error) { diff --git a/src/components/ConnectionStatus.tsx b/src/components/ConnectionStatus.tsx index 0d7fbbe..9341d1f 100644 --- a/src/components/ConnectionStatus.tsx +++ b/src/components/ConnectionStatus.tsx @@ -57,7 +57,7 @@ export function ConnectionStatus() { color: 'gray' as const, icon: , text: 'No Home Assistant', - description: 'Running in development mode', + description: 'Home Assistant connection not available', }, disconnected: { color: 'red' as const, @@ -165,7 +165,7 @@ export function ConnectionStatus() { )} - {/* Dev Mode Notice */} + {/* No Connection Notice */} {!hass && ( <> diff --git a/src/components/DevHomeAssistantProvider.tsx b/src/components/DevHomeAssistantProvider.tsx deleted file mode 100644 index 8122ead..0000000 --- a/src/components/DevHomeAssistantProvider.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { ReactNode } from 'react' -import { HomeAssistantProvider } from '~/contexts/HomeAssistantContext' -import { useDevHass } from '~/hooks/useDevHass' - -export function DevHomeAssistantProvider({ children }: { children: ReactNode }) { - const devHass = useDevHass() - - // Always wrap children in the provider, even with null hass - // This prevents "useHomeAssistant must be used within a HomeAssistantProvider" errors - return {children} -} diff --git a/src/components/EntityCard.tsx b/src/components/EntityCard.tsx index 4bbadbb..e7ed9bc 100644 --- a/src/components/EntityCard.tsx +++ b/src/components/EntityCard.tsx @@ -1,16 +1,13 @@ import { useContext } from 'react' import { Card, Flex, Text, Switch, Heading } from '@radix-ui/themes' import { HomeAssistantContext } from '~/contexts/HomeAssistantContext' -import { useDevHass } from '~/hooks/useDevHass' interface EntityCardProps { entityId: string } export function EntityCard({ entityId }: EntityCardProps) { - const hassFromContext = useContext(HomeAssistantContext) - const hassFromDev = useDevHass() - const hass = hassFromContext || hassFromDev + const hass = useContext(HomeAssistantContext) if (!hass) { return ( diff --git a/src/components/RemoteHomeAssistantProvider.tsx b/src/components/RemoteHomeAssistantProvider.tsx new file mode 100644 index 0000000..bdc514a --- /dev/null +++ b/src/components/RemoteHomeAssistantProvider.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from 'react' +import { HomeAssistantProvider } from '~/contexts/HomeAssistantContext' +import { useRemoteHass } from '~/hooks/useRemoteHass' + +interface RemoteHomeAssistantProviderProps { + children: ReactNode +} + +/** + * Provider that handles Home Assistant connection for remote deployments + * Receives hass object via postMessage when running in an iframe + */ +export function RemoteHomeAssistantProvider({ children }: RemoteHomeAssistantProviderProps) { + const hass = useRemoteHass() + + if (!hass) { + // Show loading state while waiting for hass object + return ( +
+
+
+ Connecting to Home Assistant... +
+
+ Make sure Liebe is properly configured in your Home Assistant +
+
+
+ ) + } + + return {children} +} diff --git a/src/components/ViewTabs.tsx b/src/components/ViewTabs.tsx index 0f9419b..d6f1f55 100644 --- a/src/components/ViewTabs.tsx +++ b/src/components/ViewTabs.tsx @@ -46,17 +46,6 @@ export function ViewTabs({ onAddView }: ViewTabsProps) { if (screen) { // Navigate to the new screen using slug navigate({ to: '/$slug', params: { slug: screen.slug } }) - - // If we're in an iframe, notify the parent window - if (window.parent !== window) { - window.parent.postMessage( - { - type: 'route-change', - path: `/${screen.slug}`, - }, - '*' - ) - } } } diff --git a/src/components/__tests__/ConfigurationMenu.test.tsx b/src/components/__tests__/ConfigurationMenu.test.tsx index 05646eb..725ba3e 100644 --- a/src/components/__tests__/ConfigurationMenu.test.tsx +++ b/src/components/__tests__/ConfigurationMenu.test.tsx @@ -90,7 +90,7 @@ describe('ConfigurationMenu', () => { await user.click(screen.getByText('Export as YAML')) expect(persistence.exportConfigurationAsYAML).toHaveBeenCalled() - expect(mockLink!.download).toMatch(/^liebe-dashboard-.*\.yaml$/) + expect(mockLink!.download).toMatch(/^liebe-.*\.yaml$/) expect(mockLink!.click).toHaveBeenCalled() }) diff --git a/src/components/__tests__/ViewTabs.test.tsx b/src/components/__tests__/ViewTabs.test.tsx index 96112b1..9625d50 100644 --- a/src/components/__tests__/ViewTabs.test.tsx +++ b/src/components/__tests__/ViewTabs.test.tsx @@ -353,7 +353,7 @@ describe('ViewTabs', () => { }) }) - it('should send postMessage when navigating in iframe', async () => { + it('should navigate to correct route when clicking tab', async () => { const screen1 = createTestScreen({ id: 'screen-1', name: 'Living Room', @@ -376,13 +376,11 @@ describe('ViewTabs', () => { const kitchenTab = screen.getByRole('tab', { name: /Kitchen/ }) await user.click(kitchenTab) - expect(window.parent.postMessage).toHaveBeenCalledWith( - { - type: 'route-change', - path: '/kitchen', - }, - '*' - ) + // Check that navigation was called with the correct route + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/$slug', + params: { slug: 'kitchen' }, + }) }) }) }) diff --git a/src/custom-panel.ts b/src/custom-panel.ts index 513bc87..a45a221 100644 --- a/src/custom-panel.ts +++ b/src/custom-panel.ts @@ -18,7 +18,7 @@ interface Panel { } // Home Assistant custom panel element -class LiebeDashboardPanel extends HTMLElement { +class LiebePanel extends HTMLElement { private _hass: HomeAssistant | null = null private root?: ReactDOM.Root private _panel?: Panel @@ -106,4 +106,4 @@ class LiebeDashboardPanel extends HTMLElement { } // Register the custom element -customElements.define('liebe-dashboard-panel', LiebeDashboardPanel) +customElements.define('liebe', LiebePanel) diff --git a/src/hooks/__tests__/useHomeAssistantRouting.test.ts b/src/hooks/__tests__/useHomeAssistantRouting.test.ts index 39abf44..a557381 100644 --- a/src/hooks/__tests__/useHomeAssistantRouting.test.ts +++ b/src/hooks/__tests__/useHomeAssistantRouting.test.ts @@ -125,73 +125,9 @@ describe('useHomeAssistantRouting', () => { it('should listen for navigation messages', () => { renderHook(() => useHomeAssistantRouting()) - expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)) expect(addEventListenerSpy).toHaveBeenCalledWith('liebe-navigate', expect.any(Function)) }) - it('should navigate when receiving navigate-to message', () => { - renderHook(() => useHomeAssistantRouting()) - - // Get the message handler - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const messageCall = addEventListenerSpy.mock.calls.find((call: any) => call[0] === 'message') - const messageHandler = messageCall![1] as EventListener - - // Simulate message event - messageHandler( - new MessageEvent('message', { - data: { - type: 'navigate-to', - path: '/new-path', - }, - }) - ) - - expect(mockNavigate).toHaveBeenCalledWith({ to: '/new-path' }) - }) - - it('should navigate when receiving current-route message', () => { - renderHook(() => useHomeAssistantRouting()) - - // Get the message handler - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const messageCall = addEventListenerSpy.mock.calls.find((call: any) => call[0] === 'message') - const messageHandler = messageCall![1] as EventListener - - // Simulate message event with different route - messageHandler( - new MessageEvent('message', { - data: { - type: 'current-route', - path: '/parent-route', - }, - }) - ) - - expect(mockNavigate).toHaveBeenCalledWith({ to: '/parent-route' }) - }) - - it('should not navigate if current-route is same as current', () => { - renderHook(() => useHomeAssistantRouting()) - - // Get the message handler - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const messageCall = addEventListenerSpy.mock.calls.find((call: any) => call[0] === 'message') - const messageHandler = messageCall![1] as EventListener - - // Simulate message event with same route - messageHandler( - new MessageEvent('message', { - data: { - type: 'current-route', - path: '/test-path', // Same as mockRouterState.location.pathname - }, - }) - ) - - expect(mockNavigate).not.toHaveBeenCalled() - }) - it('should handle liebe-navigate custom event', () => { renderHook(() => useHomeAssistantRouting()) @@ -236,7 +172,6 @@ describe('useHomeAssistantRouting', () => { unmount() - expect(removeEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)) expect(removeEventListenerSpy).toHaveBeenCalledWith('liebe-navigate', expect.any(Function)) }) }) @@ -248,13 +183,18 @@ describe('useHomeAssistantRouting', () => { value: { pathname: '/some-other-path' }, writable: true, }) + + // Mock that we're not in an iframe + Object.defineProperty(window, 'parent', { + value: window, + writable: true, + }) }) it('should not set up any listeners', () => { renderHook(() => useHomeAssistantRouting()) expect(mockSubscribe).not.toHaveBeenCalled() - expect(addEventListenerSpy).not.toHaveBeenCalledWith('message', expect.any(Function)) expect(addEventListenerSpy).not.toHaveBeenCalledWith('liebe-navigate', expect.any(Function)) }) }) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 3d282aa..19ba3d1 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,3 +4,4 @@ export { useEntityConnection } from './useEntityConnection' export { useServiceCall } from './useServiceCall' export { useEntityAttribute, useEntityAttributes } from './useEntityAttribute' export { useHomeAssistantRouting } from './useHomeAssistantRouting' +export { useRemoteHass } from './useRemoteHass' diff --git a/src/hooks/useDevHass.ts b/src/hooks/useDevHass.ts deleted file mode 100644 index e7e43c9..0000000 --- a/src/hooks/useDevHass.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useState, useEffect } from 'react' -import type { HomeAssistant } from '~/contexts/HomeAssistantContext' - -// Hook to receive hass object from parent frame in development -export function useDevHass(): HomeAssistant | null { - const [hass, setHass] = useState(null) - - useEffect(() => { - // Only listen if we're in an iframe - if (window.parent === window) return - - const handleMessage = (event: MessageEvent) => { - if (event.data.type === 'hass-update') { - // Create a hass proxy that sends service calls back to parent - const hassProxy: HomeAssistant = { - ...event.data.hass, - callService: async ( - domain: string, - service: string, - serviceData?: Record - ) => { - window.parent.postMessage( - { - type: 'call-service', - domain, - service, - serviceData, - }, - '*' - ) - }, - connection: { - subscribeEvents: () => { - // Event subscription not available in development iframe - return () => {} - }, - }, - } - setHass(hassProxy) - } - } - - window.addEventListener('message', handleMessage) - return () => window.removeEventListener('message', handleMessage) - }, []) - - return hass -} diff --git a/src/hooks/useHomeAssistantRouting.ts b/src/hooks/useHomeAssistantRouting.ts index 88ba9b3..ecae4e5 100644 --- a/src/hooks/useHomeAssistantRouting.ts +++ b/src/hooks/useHomeAssistantRouting.ts @@ -2,36 +2,25 @@ import { useEffect } from 'react' import { useRouter } from '@tanstack/react-router' /** - * Hook to sync routing between the dashboard and Home Assistant parent window + * Hook to sync routing between the dashboard and Home Assistant custom panel * This enables proper URL updates when navigating within the custom panel */ export function useHomeAssistantRouting() { const router = useRouter() useEffect(() => { - // Check if we're running inside Home Assistant (either in iframe or custom panel) - const isInHomeAssistant = - window.location.pathname.includes('/liebe') || window.location.pathname.includes('/liebe-dev') + const isInIframe = window.parent !== window + const isInHomeAssistant = window.location.pathname.includes('/liebe') - if (!isInHomeAssistant) return + // Skip if not in Home Assistant and not in iframe + if (!isInHomeAssistant && !isInIframe) return - // Check if we need to sync initial route from parent URL - if (window.parent !== window) { - // We're in an iframe - // Wait a tick to ensure router is initialized - setTimeout(() => { - // In iframe, we need to get the route from parent and sync - // Send a message to parent to get the current route - window.parent.postMessage({ type: 'get-route' }, '*') - }, 0) - } - - // Listen for route changes and notify parent window + // Listen for route changes const unsubscribe = router.subscribe('onResolved', () => { const currentPath = router.state.location.pathname - // If we're in an iframe (development mode), send message to parent - if (window.parent !== window) { + if (isInIframe) { + // Send route change to parent window window.parent.postMessage( { type: 'route-change', @@ -39,29 +28,16 @@ export function useHomeAssistantRouting() { }, '*' ) + } else { + // Dispatch event for custom panel integration + window.dispatchEvent( + new CustomEvent('liebe-route-change', { + detail: { path: currentPath }, + }) + ) } - - // Always dispatch event for custom panel integration - window.dispatchEvent( - new CustomEvent('liebe-route-change', { - detail: { path: currentPath }, - }) - ) }) - // Listen for navigation requests from parent window or custom panel - const handleMessage = (event: MessageEvent) => { - if (event.data.type === 'navigate-to') { - router.navigate({ to: event.data.path }) - } else if (event.data.type === 'current-route') { - // Response from parent with current route - const parentRoute = event.data.path - if (parentRoute && parentRoute !== '/' && parentRoute !== router.state.location.pathname) { - router.navigate({ to: parentRoute }) - } - } - } - // Listen for navigation from custom panel element const handleNavigate = (event: Event) => { const customEvent = event as CustomEvent @@ -70,12 +46,18 @@ export function useHomeAssistantRouting() { } } - window.addEventListener('message', handleMessage) window.addEventListener('liebe-navigate', handleNavigate) + // If in iframe, request current route from parent + if (isInIframe) { + // Small delay to ensure everything is set up + setTimeout(() => { + window.parent.postMessage({ type: 'get-route' }, '*') + }, 100) + } + return () => { unsubscribe() - window.removeEventListener('message', handleMessage) window.removeEventListener('liebe-navigate', handleNavigate) } }, [router]) diff --git a/src/hooks/useRemoteHass.ts b/src/hooks/useRemoteHass.ts new file mode 100644 index 0000000..044dfcf --- /dev/null +++ b/src/hooks/useRemoteHass.ts @@ -0,0 +1,107 @@ +import { useState, useEffect } from 'react' +import type { HomeAssistant } from '~/contexts/HomeAssistantContext' + +/** + * Hook to receive Home Assistant object via postMessage + * Used when running from a remote server or localhost + */ +export function useRemoteHass(): HomeAssistant | null { + const [hass, setHass] = useState(null) + + useEffect(() => { + // Check if we're running inside an iframe (remote mode) + const isInIframe = window.parent !== window + + if (!isInIframe) { + return + } + + const handleMessage = (event: MessageEvent) => { + if (event.data.type === 'hass-update' && event.data.hass) { + // Create a proxy hass object that sends service calls back to parent + const proxyHass = { + ...event.data.hass, + callService: async ( + domain: string, + service: string, + serviceData?: Record + ) => { + return new Promise((resolve, reject) => { + const id = Math.random().toString(36).substr(2, 9) + + const responseHandler = (responseEvent: MessageEvent) => { + if ( + responseEvent.data.type === 'service-response' && + responseEvent.data.id === id + ) { + window.removeEventListener('message', responseHandler) + if (responseEvent.data.success) { + resolve(undefined) + } else { + reject(new Error(responseEvent.data.error || 'Service call failed')) + } + } + } + + window.addEventListener('message', responseHandler) + + window.parent.postMessage( + { + type: 'call-service', + domain, + service, + serviceData, + id, + }, + '*' + ) + + // Timeout after 10 seconds + setTimeout(() => { + window.removeEventListener('message', responseHandler) + reject(new Error('Service call timeout')) + }, 10000) + }) + }, + } as HomeAssistant + + setHass(proxyHass) + + // Also update entity store with new states + if (event.data.hass.states) { + // Trigger a custom event to update the entity store + window.dispatchEvent( + new CustomEvent('hass-states-update', { + detail: { states: event.data.hass.states }, + }) + ) + } + } else if (event.data.type === 'state-changed' && event.data.event) { + // Handle state change events from parent + window.dispatchEvent( + new CustomEvent('hass-state-changed', { + detail: event.data.event, + }) + ) + } else if (event.data.type === 'navigate-to' && event.data.path) { + // Handle navigation requests from parent + window.dispatchEvent( + new CustomEvent('liebe-navigate', { + detail: { path: event.data.path }, + }) + ) + } + } + + window.addEventListener('message', handleMessage) + + // Request initial hass object + window.parent.postMessage({ type: 'get-hass' }, '*') + + return () => { + window.removeEventListener('message', handleMessage) + } + }, []) + + return hass +} diff --git a/src/router.tsx b/src/router.tsx index a0fdffe..46617fc 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -5,20 +5,12 @@ import { NotFound } from './components/NotFound' export function createRouter() { // Determine base path for Home Assistant custom panel - // In HA, the panel is served at /liebe/ (production) or /liebe-dev/ (development) + // In HA, the panel is served at /liebe/ let basepath: string | undefined = undefined if (typeof window !== 'undefined') { - // Only use base path if we're NOT in an iframe - // In iframe mode, we handle routing differently - const isInIframe = window.parent !== window - - if (!isInIframe) { - if (window.location.pathname.includes('/liebe-dev')) { - basepath = '/liebe-dev' - } else if (window.location.pathname.includes('/liebe')) { - basepath = '/liebe' - } + if (window.location.pathname.includes('/liebe')) { + basepath = '/liebe' } } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 3c81e39..1e2e9d3 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -6,7 +6,7 @@ import '@radix-ui/themes/styles.css' import * as React from 'react' import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' import { NotFound } from '~/components/NotFound' -import { DevHomeAssistantProvider } from '~/components/DevHomeAssistantProvider' +import { RemoteHomeAssistantProvider } from '~/components/RemoteHomeAssistantProvider' import { useHomeAssistantRouting } from '~/hooks/useHomeAssistantRouting' import { useDashboardPersistence } from '~/store' import '~/styles/app.css' @@ -24,15 +24,23 @@ function RootComponent() { // Enable Home Assistant routing sync useHomeAssistantRouting() - return ( + // Check if we're running in an iframe (remote mode) + const isInIframe = typeof window !== 'undefined' && window.parent !== window + + const content = ( <> - - - - + + ) + + // Wrap with RemoteHomeAssistantProvider if in iframe + if (isInIframe) { + return {content} + } + + return content } diff --git a/src/services/hassConnection.ts b/src/services/hassConnection.ts index 58196b3..f430416 100644 --- a/src/services/hassConnection.ts +++ b/src/services/hassConnection.ts @@ -104,8 +104,36 @@ export class HassConnectionManager { } private subscribeToStateChanges(): void { + // Check if we're in iframe mode (no WebSocket connection) if (!this.hass?.connection) { - throw new Error('Home Assistant connection not available') + // In iframe mode, we get state updates via the hass object itself + // No need to subscribe to WebSocket events + console.info('Running in iframe mode - state updates handled via postMessage') + + // Listen for state change events from parent window + const handleIframeStateChange = (event: CustomEvent) => { + this.handleStateChanged(event.detail as StateChangedEvent) + } + + const handleStatesUpdate = (event: CustomEvent) => { + if (event.detail?.states) { + // Convert states object to array of entities + const entities = Object.values(event.detail.states) as HassEntity[] + entityStoreActions.updateEntities(entities) + entityStoreActions.setInitialLoading(false) + } + } + + window.addEventListener('hass-state-changed', handleIframeStateChange as EventListener) + window.addEventListener('hass-states-update', handleStatesUpdate as EventListener) + + // Store cleanup function + this.stateChangeUnsubscribe = () => { + window.removeEventListener('hass-state-changed', handleIframeStateChange as EventListener) + window.removeEventListener('hass-states-update', handleStatesUpdate as EventListener) + } + + return } try { diff --git a/src/store/__tests__/persistence.test.ts b/src/store/__tests__/persistence.test.ts index 4b95972..70aa3b2 100644 --- a/src/store/__tests__/persistence.test.ts +++ b/src/store/__tests__/persistence.test.ts @@ -62,7 +62,7 @@ describe('persistence', () => { saveDashboardConfig(mockConfig) expect(localStorageMock.setItem).toHaveBeenCalledWith( - 'liebe-dashboard-config', + 'liebe-config', JSON.stringify(mockConfig) ) }) @@ -84,7 +84,7 @@ describe('persistence', () => { const loaded = loadDashboardConfig() expect(loaded).toEqual(mockConfig) - expect(localStorageMock.getItem).toHaveBeenCalledWith('liebe-dashboard-config') + expect(localStorageMock.getItem).toHaveBeenCalledWith('liebe-config') }) it('should return null if no config exists', () => { @@ -136,7 +136,7 @@ describe('persistence', () => { it('should remove config from localStorage and reset state', () => { clearDashboardConfig() - expect(localStorageMock.removeItem).toHaveBeenCalledWith('liebe-dashboard-config') + expect(localStorageMock.removeItem).toHaveBeenCalledWith('liebe-config') expect(dashboardStore.state.screens).toEqual([]) }) @@ -167,7 +167,7 @@ describe('persistence', () => { expect(createElementSpy).toHaveBeenCalledWith('a') expect(mockElement.setAttribute).toHaveBeenCalledWith( 'download', - expect.stringMatching(/^liebe-dashboard-\d{4}-\d{2}-\d{2}\.json$/) + expect.stringMatching(/^liebe-\d{4}-\d{2}-\d{2}\.json$/) ) expect(clickSpy).toHaveBeenCalled() expect(mockElement.remove).toHaveBeenCalled() diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 8466dc7..a1f3126 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -3,7 +3,7 @@ import { dashboardStore, dashboardActions } from './dashboardStore' import type { DashboardConfig } from './types' import { generateSlug, ensureUniqueSlug } from '../utils/slug' -const STORAGE_KEY = 'liebe-dashboard-config' +const STORAGE_KEY = 'liebe-config' export const saveDashboardConfig = (config: DashboardConfig): void => { try { @@ -134,7 +134,7 @@ export const exportConfigurationToFile = (): void => { const dataStr = JSON.stringify(config, null, 2) const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr) - const exportFileDefaultName = `liebe-dashboard-${new Date().toISOString().split('T')[0]}.json` + const exportFileDefaultName = `liebe-${new Date().toISOString().split('T')[0]}.json` const linkElement = document.createElement('a') linkElement.setAttribute('href', dataUri) diff --git a/vite.config.ha.ts b/vite.config.ha.ts index 7127e18..43b6bfe 100644 --- a/vite.config.ha.ts +++ b/vite.config.ha.ts @@ -6,11 +6,11 @@ export default defineConfig({ build: { lib: { entry: resolve(__dirname, 'src/custom-panel.ts'), - name: 'LiebeDashboard', - fileName: 'custom-panel', + name: 'Liebe', + fileName: 'panel', formats: ['iife'], }, - outDir: 'dist/liebe-dashboard', + outDir: 'dist/liebe', emptyOutDir: true, rollupOptions: { external: [],