diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..ec2e775 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore +.DS_Store +ha-addon +custom_components +node_modules +storage/@eaDir +config/@eaDir +config/config.json +lib/@eaDir +assets/@eaDir +storage/*.json diff --git a/@eaDir/wallpanell.code-workspace@SynoEAStream b/@eaDir/wallpanell.code-workspace@SynoEAStream new file mode 100755 index 0000000..a1ae015 Binary files /dev/null and b/@eaDir/wallpanell.code-workspace@SynoEAStream differ diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..f34ea00 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM php:8.2-cli-alpine + +RUN apk add --no-cache curl-dev && docker-php-ext-install curl + +WORKDIR /app + +COPY index.php /app/index.php +COPY api.php /app/api.php +COPY favicon.ico /app/favicon.ico +COPY assets /app/assets +COPY lib /app/lib +COPY config/addon-default-config.json /app/config/config.json +COPY run.sh /run.sh + +RUN chmod a+x /run.sh + +EXPOSE 8099 + +CMD ["/run.sh"] diff --git a/README.md b/README.md index 77b37f6..790a9b3 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Wall Panel Таблет-ориентированная панель для Home Assistant на `PHP + HTML + JS`. +Основной HA-способ запуска теперь `Home Assistant Add-on` с ingress и отдельным портом. ## Запуск @@ -14,7 +15,7 @@ php -S 0.0.0.0:8080 Основной файл: -- [`config/config.json`](/Users/striker/SynologyDrive/developer/HomeAssistant/wallpanell/config/config.json) +- [`config/config.json`](config/config.json) В него кладутся: @@ -27,6 +28,45 @@ php -S 0.0.0.0:8080 Если `base_url` и `token` пустые, панель работает в demo mode с тестовыми карточками. +## Home Assistant Add-on + +Для HA теперь рекомендован add-on-режим: + +- запускает тот же PHP-интерфейс внутри Supervisor; +- открывается через ingress; +- доступен через отдельный порт; +- хранит конфиг в add-on volume; +- не зависит от `custom_components`. + +Файлы add-on лежат в корне репозитория: + +- [`config.yaml`](/Volumes/web/wallpanell/config.yaml) +- [`Dockerfile`](/Volumes/web/wallpanell/Dockerfile) +- [`run.sh`](/Volumes/web/wallpanell/run.sh) + +### Как использовать + +1. Поместите репозиторий в локальные add-ons Home Assistant или соберите add-on как свой локальный пакет. +2. Установите add-on. +3. Запустите его. +4. Откройте панель через ingress или через проброшенный порт `8099/tcp`. + +### Конфигурация + +Add-on использует persistent config file: + +- `/config/config.json` внутри контейнера + +Этот файл является runtime source of truth для панели в HA-режиме и переживает рестарт add-on. + +### Старый embed-режим + +Отдельный PHP-доступ по-прежнему работает: + +`http://YOUR_PANEL_HOST/index.php?embed=1` + +Это полезно, если вы запускаете панель вне HA или хотите iframe-карточку вручную. + ## Popup камеры Для браузера нужен не прямой `rtsp://`, а bridge, который отдаёт `HLS` или `WebRTC`. diff --git a/api.php b/api.php index d551e2d..0ed4b91 100755 --- a/api.php +++ b/api.php @@ -27,6 +27,17 @@ function api_input(): array return $_POST ?: []; } +function api_mirror_config_change(array $config, string $action, array $payload): array +{ + $response = app_sync_remote_action($config, $action, $payload); + if (is_array($response) && is_array($response['config'] ?? null)) { + $config = app_merge_synced_config($config, $response['config']); + app_save_config($config); + } + + return $config; +} + try { $config = app_load_config(); $client = new HomeAssistantClient($config); @@ -109,6 +120,8 @@ try { ]; $config = app_update_entity_override($config, $roomId, $entityId, $patch); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); } @@ -132,6 +145,8 @@ try { } $config = app_update_room_override($config, $roomId, $patch); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); } @@ -152,6 +167,8 @@ try { $config = app_update_room_layout_item($config, $roomId, $layoutItemId, [ 'order' => $order, ]); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); api_json([ 'ok' => true, @@ -174,6 +191,8 @@ try { ]; $config = app_update_room_layout_item($config, $roomId, $layoutItemId, $patch); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); } @@ -187,6 +206,8 @@ try { } $config = app_delete_room_layout_item($config, $roomId, $layoutItemId); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); } @@ -200,6 +221,8 @@ try { } $config = app_reorder_room_grid($config, $roomId, $entries); + $config = api_mirror_config_change($config, $action, $payload); + app_save_config($config); api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); } @@ -211,6 +234,7 @@ try { if (array_key_exists('title', $payload) && trim((string)$payload['title']) !== '') { $config['app']['title'] = trim((string)$payload['title']); } + $config = api_mirror_config_change($config, $action, $payload); app_save_config($config); api_json(['ok' => true, 'settings' => $config['app']]); } diff --git a/assets/app.css b/assets/app.css index 98866f8..74d54ab 100755 --- a/assets/app.css +++ b/assets/app.css @@ -45,6 +45,10 @@ body { overflow: hidden; } +body.is-embedded { + overflow: auto; +} + button, input, select, @@ -55,9 +59,16 @@ textarea { .app-shell { display: grid; grid-template-columns: var(--sidebar-width) 1fr; + min-height: 100vh; height: 100vh; } +.app-shell--embed { + grid-template-columns: clamp(250px, 22vw, 320px) minmax(0, 1fr); + height: auto; + min-height: 100vh; +} + .sidebar { position: relative; padding: 24px 20px 20px 20px; @@ -69,6 +80,11 @@ textarea { overflow: hidden; } +.app-shell--embed .sidebar { + padding: 18px 16px 16px; + border-right: 1px solid rgba(255, 255, 255, 0.05); +} + .sidebar::after { content: ""; position: absolute; @@ -80,10 +96,18 @@ textarea { mix-blend-mode: screen; } +.app-shell--embed .sidebar::after { + display: none; +} + .clock-panel { padding: 12px 8px 12px 8px; } +.app-shell--embed .clock-panel { + padding: 0 0 8px; +} + .clock-panel__time { font-family: var(--font-display); font-size: clamp(56px, 6.4vw, 82px); @@ -100,6 +124,15 @@ textarea { white-space: nowrap; } +.app-shell--embed .clock-panel__time { + font-size: clamp(34px, 3.8vw, 56px); +} + +.app-shell--embed .clock-panel__date { + font-size: 14px; + white-space: normal; +} + .rooms-panel { margin-top: 12px; display: flex; @@ -108,6 +141,10 @@ textarea { flex: 1; } +.app-shell--embed .rooms-panel { + margin-top: 8px; +} + .panel-header, .content-header, .camera-modal__header, @@ -215,6 +252,13 @@ textarea { padding-bottom: 6px; } +.app-shell--embed .room-list { + gap: 10px; + overflow-y: auto; + padding-right: 4px; + padding-bottom: 4px; +} + .room-list__group { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -239,6 +283,24 @@ textarea { transition: border-color 180ms ease, transform 180ms ease, background 180ms ease, box-shadow 180ms ease; } +.app-shell--embed .room-item { + min-height: 94px; + padding: 7px 8px; +} + +.app-shell--embed .room-item__icon { + width: 42px; + height: 42px; +} + +.app-shell--embed .room-item__icon i { + font-size: 32px; +} + +.app-shell--embed .room-item__name { + font-size: 14px; +} + .room-item.has-temp { padding-right: 0px; } @@ -443,12 +505,20 @@ textarea { overflow: auto; } +.app-shell--embed .content { + padding: 22px 16px 20px; +} + .content-top { display: none; margin-bottom: 16px; margin-top: -30px; } +.app-shell--embed .content-top { + margin-top: 0; +} + .content-top.is-main { display: block; } @@ -459,6 +529,11 @@ textarea { justify-content: flex-start; } +.app-shell--embed .content-header { + min-height: auto; + margin-bottom: 12px; +} + .content-header.is-main .content-header__eyebrow, .content-header.is-main .content-header__meta { display: none; @@ -483,6 +558,10 @@ textarea { letter-spacing: -0.05em; } +.app-shell--embed .content-header__title { + font-size: clamp(24px, 2.5vw, 40px); +} + .content-header__meta { margin-top: 10px; color: var(--text-muted); diff --git a/assets/app.js b/assets/app.js index 0f4ab16..7538504 100755 --- a/assets/app.js +++ b/assets/app.js @@ -3,6 +3,7 @@ const MOBILE_BREAKPOINT = 920; const state = { snapshot: bootstrap, + embedMode: Boolean(bootstrap?.ui?.embed), selectedRoomId: 'main', isMobileViewport: false, mobileView: 'spaces', @@ -40,6 +41,7 @@ haReconnectDelay: 1000, haSubscribeId: 1, roomSelectionToken: 0, + snapshotPollTimer: null, }; const els = {}; @@ -68,6 +70,18 @@ return isMobileViewport() && state.mobileView === 'room'; } + function detectEmbeddedContext() { + if (Boolean(bootstrap?.ui?.embed)) { + return true; + } + + try { + return window.self !== window.top; + } catch (error) { + return true; + } + } + function setMobileView(nextView) { if (!isMobileViewport()) { state.mobileView = 'room'; @@ -897,9 +911,12 @@ if (!els.appShell) return; const mobile = isMobileViewport(); + const embedded = Boolean(state.embedMode); document.body.classList.toggle('is-mobile-ui', mobile); + document.body.classList.toggle('is-embedded', embedded); els.appShell.classList.toggle('is-mobile', mobile); els.appShell.classList.toggle('is-desktop', !mobile); + els.appShell.classList.toggle('app-shell--embed', embedded); els.appShell.classList.toggle('mobile-view-spaces', mobile && state.mobileView !== 'room'); els.appShell.classList.toggle('mobile-view-room', mobile && state.mobileView === 'room'); @@ -4290,6 +4307,7 @@ clearTimeout(state.haReconnectTimer); state.haReconnectTimer = null; state.haSocketState = 'disconnected'; + stopSnapshotPolling(); } function scheduleReconnect() { @@ -4302,6 +4320,29 @@ }, delay); } + function stopSnapshotPolling() { + if (state.snapshotPollTimer) { + clearInterval(state.snapshotPollTimer); + state.snapshotPollTimer = null; + } + } + + function startSnapshotPolling() { + const interval = Math.max(1000, Number(state.snapshot?.settings?.poll_interval_ms || bootstrap?.settings?.poll_interval_ms || 5000)); + if (state.snapshotPollTimer) { + clearInterval(state.snapshotPollTimer); + } + + state.snapshotPollTimer = window.setInterval(async () => { + try { + await loadSnapshot(state.selectedRoomId || 'main'); + render(); + } catch (error) { + console.warn(error); + } + }, interval); + } + function handleHaMessage(message) { if (!message || typeof message !== 'object') { return; @@ -4433,9 +4474,18 @@ if (!wsUrl || !token) { state.haSocketState = 'unavailable'; setStatus('Online', 'online'); + startSnapshotPolling(); return; } + if (window.location.protocol === 'https:' && wsUrl.startsWith('ws://')) { + state.haSocketState = 'unavailable'; + setStatus('Polling mode', 'online'); + startSnapshotPolling(); + return; + } + + stopSnapshotPolling(); stopRealtime(); state.haSocketState = 'connecting'; setStatus('Connecting WS...', 'loading'); @@ -4455,6 +4505,7 @@ }; socket.onerror = () => { setStatus('WS error', 'error'); + startSnapshotPolling(); }; socket.onclose = () => { state.haSocket = null; @@ -4470,6 +4521,8 @@ async function start() { initRefs(); + state.embedMode = detectEmbeddedContext(); + syncLayoutState(); syncViewportState(); updateClock(); clearInterval(state.clockTimer); @@ -4491,6 +4544,9 @@ state.snapshot = initial; render(); connectRealtime(); + if (!state.snapshotPollTimer) { + startSnapshotPolling(); + } } document.addEventListener('DOMContentLoaded', start); diff --git a/config.yaml b/config.yaml new file mode 100755 index 0000000..584ad7d --- /dev/null +++ b/config.yaml @@ -0,0 +1,24 @@ +name: Wall Panel +description: Wall Panel as a Home Assistant add-on +version: "1.0.0" +slug: wall_panel +init: false +arch: + - aarch64 + - amd64 + - armhf + - armv7 + - i386 +startup: services +ingress: true +ingress_port: 8099 +panel_title: Wall Panel +panel_icon: mdi:view-dashboard +ports: + 8099/tcp: 8099 +ports_description: + 8099/tcp: Wall Panel web UI +map: + - addon_config:rw +options: {} +schema: {} diff --git a/config/addon-default-config.json b/config/addon-default-config.json new file mode 100755 index 0000000..a6393d7 --- /dev/null +++ b/config/addon-default-config.json @@ -0,0 +1,32 @@ +{ + "app": { + "title": "Wall Panel", + "poll_interval_ms": 5000, + "main_room_name": "Главная", + "main_room_icon": "mdi:home", + "edit_mode": false, + "battery_history_hours": 4320 + }, + "home_assistant": { + "base_url": "", + "token": "", + "verify_ssl": true, + "sync_url": "", + "sync_token": "", + "sync_timeout": 10, + "sync_verify_ssl": true, + "sync_cache_seconds": 30, + "weather_entity_id": "", + "auto_label": "auto", + "auto_entity_ids": [] + }, + "camera": { + "rtsp_url": "", + "stream_url": "", + "stream_mode": "hls", + "poster_url": "", + "popup_timeout_minutes": 3, + "trigger_entities": [] + }, + "rooms": [] +} diff --git a/config/config.json b/config/config.json index 2ecaa6a..60a7ccf 100755 --- a/config/config.json +++ b/config/config.json @@ -73,6 +73,11 @@ "base_url": "http://10.0.6.5:8123", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiYWQ2ZGE0OGYzOWU0MDFjOTA2OTZhZjVkYzA2M2U2MSIsImlhdCI6MTc3MzY0NjMwNywiZXhwIjoyMDg5MDA2MzA3fQ.u7kWa3ONMEo5UMi3suIBTocr_6FqOYphfYb7NJGZ3wI", "verify_ssl": false, + "sync_url": "http://10.0.6.5:8123/api/wall_panel/config/01KMDWVSTKC9XK7RZWJ9QP9PC1", + "sync_token": "FD5i6G8JM_rwoEMqTZzSbVDSkz-1k67n", + "sync_timeout": 10, + "sync_verify_ssl": true, + "sync_cache_seconds": 30, "weather_entity_id": "", "auto_label": "auto", "auto_entity_ids": [] @@ -1336,6 +1341,141 @@ }, "sensor.aqara_dimmer_switch_active_current": { "visible": false + }, + "switch.0xc02cedfffef131dc_turbo_mode": { + "visible": false + }, + "switch.0xc02cedfffef131dc_delayed_power_on_state": { + "visible": false + }, + "number.0xc02cedfffef131dc_delayed_power_on_time": { + "visible": false + }, + "switch.0xc02cedfffef131dc_detach_relay_mode": { + "visible": false + }, + "select.0xc02cedfffef131dc_external_trigger_mode": { + "visible": false + }, + "switch.0xc02cedfffef131dc_network_indicator": { + "visible": false + }, + "select.0xc02cedfffef131dc_power_on_behavior": { + "visible": false + }, + "sensor.0x54ef4410011cd300_power": { + "visible": false + }, + "sensor.0x54ef4410011cd300_voltage": { + "visible": false + }, + "sensor.0x54ef4410011cd300_device_temperature": { + "visible": false + }, + "sensor.0x54ef4410011cd300_current": { + "visible": false + }, + "sensor.0x54ef4410011cd300_energy": { + "visible": false + }, + "sensor.0x54ef4410011cd300_action_rotation_angle": { + "visible": false + }, + "sensor.0x54ef4410011cd300_action_rotation_angle_speed": { + "visible": false + }, + "sensor.0x54ef4410011cd300_action_rotation_button_state": { + "visible": false + }, + "sensor.0x54ef4410011cd300_action_rotation_percent": { + "visible": false + }, + "sensor.0x54ef4410011cd300_action_rotation_percent_speed": { + "visible": false + }, + "switch.0x54ef4410011cd300_flip_indicator_light": { + "visible": false + }, + "switch.0x54ef4410011cd300_led_indicator": { + "visible": false + }, + "number.0x54ef4410011cd300_max_brightness": { + "visible": false + }, + "number.0x54ef4410011cd300_min_brightness": { + "visible": false + }, + "switch.0x54ef4410011cd300_multi_click": { + "visible": false + }, + "select.0x54ef4410011cd300_operation_mode": { + "visible": false + }, + "select.0x54ef4410011cd300_phase": { + "visible": false + }, + "select.0x54ef4410011cd300_power_on_behavior": { + "visible": false + }, + "select.0x54ef4410011cd300_sensitivity": { + "visible": false + }, + "sensor.0x54ef4410011cd300_action_rotation_time": { + "visible": false + }, + "update.0x54ef4410011cd300": { + "visible": false + }, + "update.0xc02cedfffef131dc": { + "visible": false + }, + "climate.konditsioner": { + "order": 10000 + }, + "cover.0x0ceff6fffe6cffc4": { + "order": 10010 + }, + "cover.0x0ceff6fffe6cdee0": { + "order": 10020 + }, + "cover.0x705464fffe43dee0": { + "order": 10030 + }, + "cover.0x44e2f8fffeb65d8e": { + "order": 10040 + }, + "light.0x54ef4410011cd300": { + "order": 10050 + }, + "switch.spalnia_girlianda_levoe_okno": { + "order": 10070 + }, + "switch.spalnia_girlianda_pravoe_okno": { + "order": 10080 + }, + "light.umnoe_rele": { + "order": 10060 + }, + "switch.lab_switch": { + "order": 10090 + }, + "light.spalnia_nochnik": { + "order": 10100 + }, + "switch.0xa4c138a7af3bad18": { + "order": 10110 + }, + "switch.0xa4c138bd4cead630": { + "order": 10120 + }, + "switch.flower": { + "order": 10130 + }, + "humidifier.uvlazhnitel": { + "order": 10140 + }, + "media_player.lg_webos_smart_tv": { + "order": 10150 } }, "temperature_sensor_entity_id": "sensor.spalnya_temp_temperature", diff --git a/custom_components/wall_panel/__init__.py b/custom_components/wall_panel/__init__.py new file mode 100755 index 0000000..929304c --- /dev/null +++ b/custom_components/wall_panel/__init__.py @@ -0,0 +1,48 @@ +"""Wall Panel integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import CONF_CONFIG, DOMAIN +from .frontend import async_setup_frontend +from .helpers import current_entry_config +from .views import WallPanelConfigView + + +async def async_setup_entry(hass: HomeAssistant, entry) -> bool: + """Set up Wall Panel from a config entry.""" + + state = hass.data.setdefault(DOMAIN, {}) + state[entry.entry_id] = { + "entry": entry, + "config": current_entry_config(entry), + } + + panel_url_path = await async_setup_frontend(hass, entry) + state[entry.entry_id]["panel_url_path"] = panel_url_path + + if not state.get("_config_view_registered"): + hass.http.register_view(WallPanelConfigView) + state["_config_view_registered"] = True + + entry.async_on_unload(entry.add_update_listener(_async_options_updated)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry) -> bool: + """Unload Wall Panel.""" + + from homeassistant.components.frontend import async_remove_panel + + state = hass.data.get(DOMAIN, {}) + panel_url_path = str(state.get(entry.entry_id, {}).get("panel_url_path") or entry.options.get("frontend_url_path", "wall-panel") or "wall-panel").strip() + async_remove_panel(hass, panel_url_path) + state.pop(entry.entry_id, None) + return True + + +async def _async_options_updated(hass: HomeAssistant, entry) -> None: + """Reload the integration when options change.""" + + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/wall_panel/config_flow.py b/custom_components/wall_panel/config_flow.py new file mode 100755 index 0000000..73502e9 --- /dev/null +++ b/custom_components/wall_panel/config_flow.py @@ -0,0 +1,134 @@ +"""Config flow for Wall Panel.""" + +from __future__ import annotations + +import json +import secrets +from typing import Any + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, TextSelectorType + +from .const import ( + CONF_CONFIG, + CONF_FRONTEND_URL_PATH, + CONF_PANEL_URL, + CONF_REQUIRE_ADMIN, + CONF_SIDEBAR_ICON, + CONF_SIDEBAR_TITLE, + CONF_SYNC_TOKEN, + DEFAULT_FRONTEND_URL_PATH, + DEFAULT_PANEL_URL, + DEFAULT_SIDEBAR_ICON, + DEFAULT_SIDEBAR_TITLE, + DOMAIN, +) +from .helpers import config_to_json, normalize_config, parse_config_json + + +def _schema(defaults: dict[str, Any]) -> vol.Schema: + return vol.Schema({ + vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME, "Wall Panel")): str, + vol.Optional(CONF_PANEL_URL, default=defaults.get(CONF_PANEL_URL, DEFAULT_PANEL_URL)): str, + vol.Optional(CONF_SIDEBAR_TITLE, default=defaults.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE)): str, + vol.Optional(CONF_SIDEBAR_ICON, default=defaults.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON)): str, + vol.Optional(CONF_FRONTEND_URL_PATH, default=defaults.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH)): str, + vol.Optional(CONF_REQUIRE_ADMIN, default=bool(defaults.get(CONF_REQUIRE_ADMIN, False))): bool, + vol.Optional(CONF_SYNC_TOKEN, default=defaults.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24))): str, + vol.Optional( + CONF_CONFIG, + default=defaults.get(CONF_CONFIG, config_to_json(normalize_config({}))), + ): TextSelector(TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT)), + }) + + +class WallPanelConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Wall Panel.""" + + VERSION = 1 + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + errors: dict[str, str] = {} + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + try: + config = parse_config_json(user_input[CONF_CONFIG]) + except (json.JSONDecodeError, ValueError): + errors[CONF_CONFIG] = "invalid_json" + else: + data = { + CONF_NAME: user_input.get(CONF_NAME, "Wall Panel"), + CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""), + CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE), + CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON), + CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH), + CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)), + CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""), + CONF_CONFIG: config, + } + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=data[CONF_SIDEBAR_TITLE], data={}, options=data) + + defaults = { + CONF_NAME: "Wall Panel", + CONF_PANEL_URL: DEFAULT_PANEL_URL, + CONF_SIDEBAR_TITLE: DEFAULT_SIDEBAR_TITLE, + CONF_SIDEBAR_ICON: DEFAULT_SIDEBAR_ICON, + CONF_FRONTEND_URL_PATH: DEFAULT_FRONTEND_URL_PATH, + CONF_REQUIRE_ADMIN: False, + CONF_SYNC_TOKEN: secrets.token_urlsafe(24), + CONF_CONFIG: config_to_json(normalize_config({})), + } + return self.async_show_form(step_id="user", data_schema=_schema(defaults), errors=errors) + + async def async_step_import(self, user_input: dict[str, Any]): + return await self.async_step_user(user_input) + + +class WallPanelOptionsFlow(config_entries.OptionsFlow): + """Handle options for Wall Panel.""" + + def __init__(self, config_entry): + self.config_entry = config_entry + + async def async_step_init(self, user_input: dict[str, Any] | None = None): + errors: dict[str, str] = {} + if user_input is not None: + try: + config = parse_config_json(user_input[CONF_CONFIG]) + except (json.JSONDecodeError, ValueError): + errors[CONF_CONFIG] = "invalid_json" + else: + data = dict(self.config_entry.options) + data.update({ + CONF_NAME: user_input.get(CONF_NAME, data.get(CONF_NAME, "Wall Panel")), + CONF_PANEL_URL: str(user_input.get(CONF_PANEL_URL, "") or ""), + CONF_SIDEBAR_TITLE: str(user_input.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE), + CONF_SIDEBAR_ICON: str(user_input.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON), + CONF_FRONTEND_URL_PATH: str(user_input.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH), + CONF_REQUIRE_ADMIN: bool(user_input.get(CONF_REQUIRE_ADMIN, False)), + CONF_SYNC_TOKEN: str(user_input.get(CONF_SYNC_TOKEN, "") or ""), + CONF_CONFIG: config, + }) + return self.async_create_entry(title="", data=data) + + defaults = { + CONF_NAME: self.config_entry.options.get(CONF_NAME, "Wall Panel"), + CONF_PANEL_URL: self.config_entry.options.get(CONF_PANEL_URL, DEFAULT_PANEL_URL), + CONF_SIDEBAR_TITLE: self.config_entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE), + CONF_SIDEBAR_ICON: self.config_entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON), + CONF_FRONTEND_URL_PATH: self.config_entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH), + CONF_REQUIRE_ADMIN: self.config_entry.options.get(CONF_REQUIRE_ADMIN, False), + CONF_SYNC_TOKEN: self.config_entry.options.get(CONF_SYNC_TOKEN, secrets.token_urlsafe(24)), + CONF_CONFIG: config_to_json(normalize_config(self.config_entry.options.get(CONF_CONFIG, {}))), + } + return self.async_show_form(step_id="init", data_schema=_schema(defaults), errors=errors) + + +def async_get_options_flow(config_entry): + return WallPanelOptionsFlow(config_entry) diff --git a/custom_components/wall_panel/const.py b/custom_components/wall_panel/const.py new file mode 100755 index 0000000..1bd768e --- /dev/null +++ b/custom_components/wall_panel/const.py @@ -0,0 +1,17 @@ +"""Constants for Wall Panel.""" + +DOMAIN = "wall_panel" + +CONF_PANEL_URL = "panel_url" +CONF_CONFIG = "config" +CONF_SYNC_TOKEN = "sync_token" +CONF_SIDEBAR_TITLE = "sidebar_title" +CONF_SIDEBAR_ICON = "sidebar_icon" +CONF_FRONTEND_URL_PATH = "frontend_url_path" +CONF_REQUIRE_ADMIN = "require_admin" + +DEFAULT_NAME = "Wall Panel" +DEFAULT_PANEL_URL = "" +DEFAULT_SIDEBAR_TITLE = "Wall Panel" +DEFAULT_SIDEBAR_ICON = "mdi:view-dashboard" +DEFAULT_FRONTEND_URL_PATH = "wall-panel" diff --git a/custom_components/wall_panel/frontend.py b/custom_components/wall_panel/frontend.py new file mode 100755 index 0000000..a302fbd --- /dev/null +++ b/custom_components/wall_panel/frontend.py @@ -0,0 +1,68 @@ +"""Frontend registration for Wall Panel.""" + +from __future__ import annotations + +from pathlib import Path + +from homeassistant.components.frontend import async_register_built_in_panel +from homeassistant.components.http import StaticPathConfig +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_FRONTEND_URL_PATH, + CONF_PANEL_URL, + CONF_REQUIRE_ADMIN, + CONF_SIDEBAR_ICON, + CONF_SIDEBAR_TITLE, + DEFAULT_FRONTEND_URL_PATH, + DEFAULT_SIDEBAR_ICON, + DEFAULT_SIDEBAR_TITLE, + DOMAIN, +) + + +async def async_setup_frontend(hass: HomeAssistant, entry) -> str: + """Register the custom panel and static frontend assets.""" + + frontend_dir = Path(__file__).parent / "frontend" + state = hass.data.setdefault(DOMAIN, {}) + if not state.get("_static_paths_registered"): + await hass.http.async_register_static_paths([ + StaticPathConfig( + f"/api/{DOMAIN}/frontend", + str(frontend_dir), + cache_headers=False, + ), + ]) + state["_static_paths_registered"] = True + + panel_url_path = str(entry.options.get(CONF_FRONTEND_URL_PATH, DEFAULT_FRONTEND_URL_PATH) or DEFAULT_FRONTEND_URL_PATH).strip() + sidebar_title = str(entry.options.get(CONF_SIDEBAR_TITLE, DEFAULT_SIDEBAR_TITLE) or DEFAULT_SIDEBAR_TITLE).strip() + sidebar_icon = str(entry.options.get(CONF_SIDEBAR_ICON, DEFAULT_SIDEBAR_ICON) or DEFAULT_SIDEBAR_ICON).strip() + require_admin = bool(entry.options.get(CONF_REQUIRE_ADMIN, False)) + panel_url = str(entry.options.get(CONF_PANEL_URL, "") or "").strip() + + async_register_built_in_panel( + hass, + component_name="custom", + sidebar_title=sidebar_title, + sidebar_icon=sidebar_icon, + frontend_url_path=panel_url_path, + config={ + "_panel_custom": { + "name": "wall-panel-panel", + "module_url": f"/api/{DOMAIN}/frontend/panel.js", + "embed_iframe": False, + "trust_external": False, + "config": { + "panel_url": panel_url, + "panel_url_path": panel_url_path, + "entry_id": entry.entry_id, + }, + } + }, + require_admin=require_admin, + update=True, + ) + + return panel_url_path diff --git a/custom_components/wall_panel/frontend/panel.js b/custom_components/wall_panel/frontend/panel.js new file mode 100755 index 0000000..0858a3f --- /dev/null +++ b/custom_components/wall_panel/frontend/panel.js @@ -0,0 +1,133 @@ +class WallPanelPanel extends HTMLElement { + constructor() { + super(); + this._hass = null; + this._panel = null; + this._narrow = false; + this.attachShadow({ mode: 'open' }); + } + + set hass(hass) { + this._hass = hass; + this._render(); + } + + set panel(panel) { + this._panel = panel; + this._render(); + } + + set narrow(narrow) { + this._narrow = Boolean(narrow); + this._render(); + } + + connectedCallback() { + this._render(); + } + + _resolveUrl(rawUrl) { + const value = String(rawUrl || '').trim(); + if (!value) { + return ''; + } + + try { + const url = new URL(value, window.location.href); + if (!url.searchParams.has('embed')) { + url.searchParams.set('embed', '1'); + } + if (!url.searchParams.has('mode')) { + url.searchParams.set('mode', 'ha'); + } + return url.toString(); + } catch (error) { + return value; + } + } + + _render() { + if (!this.shadowRoot) { + return; + } + + const config = this._panel?.config || {}; + const customConfig = config._panel_custom || {}; + const payload = customConfig.config || customConfig || config; + const panelUrl = this._resolveUrl( + payload.panel_url || config.panel_url || customConfig.panel_url || '' + ); + + this.shadowRoot.innerHTML = ` + +
+ `; + + const wrap = this.shadowRoot.querySelector('.wrap'); + if (!wrap) { + return; + } + + if (!panelUrl) { + wrap.innerHTML = ` +