diff --git a/.gitignore b/.gitignore
index c30f40df..927f4d81 100755
--- a/.gitignore
+++ b/.gitignore
@@ -82,6 +82,9 @@ config/custom_components/tesla_charge_guard/__pycache__/
!config/custom_components/cloudflare_alerts/
!config/custom_components/cloudflare_alerts/**
config/custom_components/cloudflare_alerts/__pycache__/
+!config/custom_components/joanna_conversation/
+!config/custom_components/joanna_conversation/**
+config/custom_components/joanna_conversation/__pycache__/
config/www/community
config/www/ookla_speedtest/
config/.cache/
diff --git a/config/custom_components/joanna_conversation/README.md b/config/custom_components/joanna_conversation/README.md
new file mode 100644
index 00000000..08274d81
--- /dev/null
+++ b/config/custom_components/joanna_conversation/README.md
@@ -0,0 +1,44 @@
+# Joanna Conversation
+
+Connect an explicitly enrolled Assist satellite to an existing Joanna service.
+The service owns the user binding, shared agent, memory, capability policy and
+persistent conversation. Home Assistant owns speech recognition, TTS and the
+satellite overlay. This integration does not run a second model.
+
+Add **Joanna Conversation** through Devices & services using the server URL,
+the enrolled satellite's HA device ID and its dedicated credential. The config
+flow validates the exact device binding before creating the conversation agent.
+Use that agent in a separate Assist pipeline, then select the pipeline on the
+enrolled satellite. Keep credentials in the config entry, never in YAML/source.
+
+For a fleet that syncs the selected assistant, use the integration's **Configure**
+option to enroll each additional satellite with its own device ID and credential.
+They can share one pipeline while each keeps its server-bound profile and
+conversation. Fleet synchronization alone never grants another device access.
+
+The server fixes identity from enrollment. Another satellite, a request without
+device context, or a spoken request to become another user cannot inherit that
+identity. A household device enrolled to a personal account uses that account
+for everyone who speaks to the enrolled device.
+
+Successful answers use native follow-up listening. Context also survives a new
+wake-word turn through the server's dedicated device conversation. The agent's
+final text is returned unchanged for speech and display. Each submission has a
+unique request ID; an uncertain connection is reconciled by reading that ID,
+never by submitting the action again. A 180-second deadline or canceled pipeline
+requests server cancellation and does not claim already-started actions were undone.
+
+Revert by selecting the previous pipeline on the satellite. The other Assist
+pipelines and satellites need no changes. Revoke the device's enrollment at the
+server to disable its credential.
+
+Validation: `tools/test_joanna_conversation.py` tests the HTTP boundary with local
+fixtures. Run the repository config check before restarting Home Assistant.
+Verify native playback separately; receiving response text does not prove audio.
+
+The kiosk package can acknowledge a heard request when the native voice sensor
+enters `processing` on the Joanna Voice pipeline. Its audio-only announcement
+plays the cached `www/joanna-voice/acknowledgement.mp3`, using the private
+`joanna_voice_ack_url` secret for the local HTTP URL. This is a fixed receipt cue,
+not an agent answer or a claim that an action completed. It adds no model call
+and does not change the satellite master volume or the final Assist response.
diff --git a/config/custom_components/joanna_conversation/__init__.py b/config/custom_components/joanna_conversation/__init__.py
new file mode 100644
index 00000000..4c827646
--- /dev/null
+++ b/config/custom_components/joanna_conversation/__init__.py
@@ -0,0 +1,32 @@
+"""Connect an enrolled Assist satellite to the existing Joanna human agent."""
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .client import JoannaDeviceRouter
+
+DOMAIN = "joanna_conversation"
+PLATFORMS = [Platform.CONVERSATION]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Load one device-bound connection; the credential stays in HA."""
+ entry.runtime_data = JoannaDeviceRouter(
+ async_get_clientsession(hass), entry.data["base_url"],
+ [entry.data, *entry.options.get("satellites", [])],
+ )
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ entry.async_on_unload(entry.add_update_listener(async_reload_entry))
+ return True
+
+
+async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Apply a validated additional satellite through the native options flow."""
+ await hass.config_entries.async_reload(entry.entry_id)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload the conversation entity."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/config/custom_components/joanna_conversation/client.py b/config/custom_components/joanna_conversation/client.py
new file mode 100644
index 00000000..7e267993
--- /dev/null
+++ b/config/custom_components/joanna_conversation/client.py
@@ -0,0 +1,101 @@
+"""Bounded, idempotent request transport with no model or intent routing."""
+
+import asyncio
+from uuid import uuid4
+
+import aiohttp
+
+
+class JoannaError(Exception):
+ """A safe spoken transport failure."""
+
+
+class JoannaDeviceRouter:
+ """Select only the credential explicitly enrolled for the originating device."""
+
+ def __init__(self, session, base_url, bindings):
+ self.clients = {
+ binding["device_id"]: JoannaClient(session, base_url, binding["token"], binding["device_id"])
+ for binding in bindings
+ }
+
+ async def converse(self, text, device_id):
+ client = self.clients.get(device_id)
+ if client is None:
+ raise JoannaError("This satellite is not enrolled with Joanna.")
+ return await client.converse(text, device_id)
+
+
+class JoannaClient:
+ """One enrolled satellite and one persistent Joanna conversation."""
+
+ def __init__(self, session, base_url, token, device_id, *, wait_seconds=180, poll_seconds=1):
+ self.session = session
+ self.base_url = base_url.rstrip("/")
+ self.headers = {"Authorization": f"Bearer {token}"}
+ self.device_id = device_id
+ self.wait_seconds = wait_seconds
+ self.poll_seconds = poll_seconds
+
+ async def request(self, method, path, body=None):
+ """Never log credentials or raw upstream errors."""
+ async with self.session.request(
+ method, self.base_url + path, headers=self.headers, json=body,
+ timeout=aiohttp.ClientTimeout(total=12), allow_redirects=False,
+ ) as response:
+ if response.status >= 400:
+ raise JoannaError("Joanna is unavailable or this satellite is not authorized.")
+ try:
+ data = await response.json()
+ except (ValueError, aiohttp.ContentTypeError):
+ raise JoannaError("Joanna returned an unreadable response. Please check the request's outcome before repeating an action.") from None
+ if not isinstance(data, dict):
+ raise JoannaError("Joanna returned an unreadable response.")
+ return data
+
+ async def validate(self):
+ """Check the credential's exact device binding."""
+ status = await self.request("GET", "/api/assist/status")
+ if status.get("deviceId") != self.device_id:
+ raise JoannaError("This credential belongs to a different satellite.")
+ return status
+
+ async def converse(self, text, device_id):
+ """Submit once; recover a lost acceptance response by polling its ID."""
+ if not device_id or device_id != self.device_id:
+ raise JoannaError("This Joanna pilot is available only on the enrolled satellite.")
+ request_id = uuid4().hex
+ result_path = f"/api/assist/requests/{request_id}"
+ try:
+ async with asyncio.timeout(self.wait_seconds):
+ try:
+ result = await self.request("POST", "/api/assist/conversation", {
+ "requestId": request_id, "deviceId": device_id, "text": text,
+ })
+ except (aiohttp.ClientError, TimeoutError):
+ # The request may have been accepted. Do not submit it again.
+ result = await self.request("GET", result_path)
+ while result.get("status") not in {"complete", "failed", "canceled"}:
+ await asyncio.sleep(self.poll_seconds)
+ try:
+ result = await self.request("GET", result_path)
+ except (aiohttp.ClientError, TimeoutError):
+ continue
+ answer = result.get("text")
+ if not isinstance(answer, str) or not answer.strip():
+ raise JoannaError("Joanna could not return that answer. Please ask me to check the request's outcome.")
+ try:
+ await self.request("POST", result_path + "/ack", {})
+ except (aiohttp.ClientError, JoannaError, TimeoutError):
+ pass # A lost receipt must not discard a completed answer.
+ return {"text": answer, "success": result["status"] == "complete"}
+ except (TimeoutError, asyncio.CancelledError) as error:
+ try:
+ await asyncio.shield(self.request("POST", result_path + "/cancel", {}))
+ except (aiohttp.ClientError, JoannaError, TimeoutError):
+ pass
+ if isinstance(error, asyncio.CancelledError):
+ raise
+ raise JoannaError("That request took too long, so I asked Joanna to stop. If it involved an action, ask me to check its status before repeating it.") from None
+ except aiohttp.ClientError:
+ raise JoannaError("I lost my connection to Joanna. I cannot confirm that request's outcome. Please ask me to check before repeating an action.") from None
diff --git a/config/custom_components/joanna_conversation/config_flow.py b/config/custom_components/joanna_conversation/config_flow.py
new file mode 100644
index 00000000..91405200
--- /dev/null
+++ b/config/custom_components/joanna_conversation/config_flow.py
@@ -0,0 +1,73 @@
+"""Configure an operator-enrolled Joanna voice credential through HA."""
+
+from urllib.parse import urlsplit
+
+import aiohttp
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.core import callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers import selector
+
+from . import DOMAIN
+from .client import JoannaClient, JoannaError
+
+
+class JoannaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """One integration entry per enrolled satellite."""
+
+ VERSION = 1
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry):
+ return JoannaOptionsFlow()
+
+ async def async_step_user(self, user_input=None):
+ errors = {}
+ if user_input is not None:
+ url = urlsplit(user_input["base_url"])
+ if url.scheme not in {"http", "https"} or not url.hostname or url.username or url.password or url.query or url.fragment or url.path not in {"", "/"}:
+ errors["base"] = "invalid_url"
+ else:
+ client = JoannaClient(async_get_clientsession(self.hass), user_input["base_url"], user_input["token"], user_input["device_id"])
+ try:
+ status = await client.validate()
+ except (aiohttp.ClientError, JoannaError, TimeoutError, ValueError):
+ errors["base"] = "cannot_connect"
+ else:
+ await self.async_set_unique_id(status["deviceKey"])
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(title=user_input["name"], data=user_input)
+ return self.async_show_form(step_id="user", errors=errors, data_schema=vol.Schema({
+ vol.Required("name", default="Joanna Voice"): str,
+ vol.Required("base_url"): str,
+ vol.Required("device_id"): str,
+ vol.Required("token"): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)),
+ }))
+
+
+class JoannaOptionsFlow(config_entries.OptionsFlow):
+ """Enroll another device on a shared pipeline without sharing its credential."""
+
+ async def async_step_init(self, user_input=None):
+ errors = {}
+ if user_input is not None:
+ bindings = [self.config_entry.data, *self.config_entry.options.get("satellites", [])]
+ if any(item["device_id"] == user_input["device_id"] for item in bindings):
+ return self.async_abort(reason="already_configured")
+ client = JoannaClient(async_get_clientsession(self.hass), self.config_entry.data["base_url"], user_input["token"], user_input["device_id"])
+ try:
+ await client.validate()
+ except (aiohttp.ClientError, JoannaError, TimeoutError, ValueError):
+ errors["base"] = "cannot_connect"
+ else:
+ return self.async_create_entry(title="", data={
+ **self.config_entry.options,
+ "satellites": [*self.config_entry.options.get("satellites", []), user_input],
+ })
+ return self.async_show_form(step_id="init", errors=errors, data_schema=vol.Schema({
+ vol.Required("device_id"): str,
+ vol.Required("token"): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)),
+ }))
diff --git a/config/custom_components/joanna_conversation/conversation.py b/config/custom_components/joanna_conversation/conversation.py
new file mode 100644
index 00000000..6aabeb6d
--- /dev/null
+++ b/config/custom_components/joanna_conversation/conversation.py
@@ -0,0 +1,49 @@
+"""Use Joanna's existing human agent as an Assist conversation entity."""
+
+from homeassistant.components import conversation
+from homeassistant.components.conversation import AssistantContent, ChatLog, ConversationInput
+from homeassistant.helpers import intent
+from homeassistant.helpers.entity import DeviceInfo
+
+from . import DOMAIN
+from .client import JoannaError
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ async_add_entities([JoannaConversation(entry)])
+
+
+class JoannaConversation(conversation.ConversationEntity):
+ """Return the same Joanna answer to the satellite's TTS and overlay."""
+
+ _attr_has_entity_name = True
+ _attr_name = None
+ _attr_supported_features = conversation.ConversationEntityFeature.CONTROL
+
+ def __init__(self, entry):
+ self.entry = entry
+ self._attr_unique_id = entry.unique_id
+ self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, entry.unique_id)}, name=entry.title, manufacturer="Joanna", model="Assist bridge")
+
+ @property
+ def supported_languages(self):
+ return "*"
+
+ async def _async_handle_message(self, user_input: ConversationInput, chat_log: ChatLog):
+ response = intent.IntentResponse(language=user_input.language)
+ try:
+ result = await self.entry.runtime_data.converse(user_input.text, user_input.device_id)
+ answer = result["text"]
+ success = result["success"]
+ except JoannaError as error:
+ answer = str(error)
+ success = False
+ chat_log.async_add_assistant_content_without_tools(AssistantContent(agent_id=user_input.agent_id, content=answer))
+ if success:
+ response.async_set_speech(answer)
+ else:
+ response.async_set_error(intent.IntentResponseErrorCode.FAILED_TO_HANDLE, answer)
+ return conversation.ConversationResult(
+ response=response, conversation_id=chat_log.conversation_id,
+ continue_conversation=success,
+ )
diff --git a/config/custom_components/joanna_conversation/manifest.json b/config/custom_components/joanna_conversation/manifest.json
new file mode 100644
index 00000000..03bc6fee
--- /dev/null
+++ b/config/custom_components/joanna_conversation/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "joanna_conversation",
+ "name": "Joanna Conversation",
+ "codeowners": ["@CCOSTAN"],
+ "config_flow": true,
+ "dependencies": ["conversation"],
+ "documentation": "https://github.com/CCOSTAN/Home-AssistantConfig",
+ "integration_type": "service",
+ "iot_class": "local_polling",
+ "version": "1.0.0"
+}
diff --git a/config/custom_components/joanna_conversation/strings.json b/config/custom_components/joanna_conversation/strings.json
new file mode 100644
index 00000000..357932d9
--- /dev/null
+++ b/config/custom_components/joanna_conversation/strings.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": { "user": { "title": "Connect an enrolled Joanna satellite", "description": "Use the device-bound credential supplied by your Joanna server.", "data": { "name": "Name", "base_url": "Joanna server URL", "device_id": "Home Assistant satellite device ID", "token": "Satellite credential" } } },
+ "error": { "cannot_connect": "Cannot validate this server, credential, and device binding.", "invalid_url": "Enter an HTTP or HTTPS server URL without a path or credentials." },
+ "abort": { "already_configured": "This satellite is already configured." }
+ },
+ "options": {
+ "step": { "init": { "title": "Enroll another satellite", "description": "Add a device with its own server-issued credential to this conversation agent and shared Assist pipeline.", "data": { "device_id": "Home Assistant satellite device ID", "token": "Satellite credential" } } },
+ "error": { "cannot_connect": "Cannot validate this credential and device binding." },
+ "abort": { "already_configured": "This satellite is already enrolled on this connection." }
+ }
+}
diff --git a/config/custom_templates/kiosk_health.jinja b/config/custom_templates/kiosk_health.jinja
new file mode 100644
index 00000000..63b324dd
--- /dev/null
+++ b/config/custom_templates/kiosk_health.jinja
@@ -0,0 +1,56 @@
+{#
+######################################################################
+# @CCOSTAN - Follow Me on X
+# For more info visit https://www.vcloudinfo.com/click-here
+# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
+# -------------------------------------------------------------------
+# Kiosk Health - Shared native Kiosk Satellite health interpretation
+# Connection, route, foreground app, power, and screenshot freshness.
+# -------------------------------------------------------------------
+# Notes: Available app updates are advisory and do not raise a fault.
+######################################################################
+#}
+{% macro satellite_status(node, expected_path, suffix='', check_foreground=false, include_update=false) -%}
+ {% set invalid = ['unknown', 'unavailable', 'none', ''] %}
+ {% set screenshot = as_timestamp(states('sensor.' ~ node ~ '_last_screenshot' ~ suffix), 0) %}
+ {% set page = states('sensor.' ~ node ~ '_current_page' ~ suffix) %}
+ {% set remote_admin = states('sensor.' ~ node ~ '_remote_admin' ~ suffix) %}
+ {% set app = states('sensor.' ~ node ~ '_foreground_app' ~ suffix) if check_foreground else 'me.jxl.kiosk_satellite' %}
+ {% set battery = states('sensor.' ~ node ~ '_battery' ~ suffix) | float(0) %}
+ {% if not is_state('binary_sensor.' ~ node ~ '_connectivity' ~ suffix, 'on') %}
+ offline
+ {% elif page in invalid or remote_admin in invalid or app in invalid %}
+ telemetry unavailable
+ {% elif expected_path not in page %}
+ page drift
+ {% elif app != 'me.jxl.kiosk_satellite' %}
+ app drift
+ {% elif battery < 20 and not is_state('binary_sensor.' ~ node ~ '_charging' ~ suffix, 'on') %}
+ low battery
+ {% elif screenshot == 0 or (as_timestamp(now()) - screenshot) > 2700 %}
+ screenshot stale
+ {% elif include_update and is_state('update.' ~ node ~ '_update' ~ suffix, 'on') %}
+ update available
+ {% else %}
+ online
+ {% endif %}
+{%- endmacro %}
+
+{% macro attention_devices() -%}
+ {% set ns = namespace(items=[]) %}
+ {% for room in ['Bedroom', 'Kitchen', 'Upstairs', 'Office'] %}
+ {% if is_state('binary_sensor.' ~ (room | lower) ~ '_kiosk_needs_attention', 'on') %}
+ {% set ns.items = ns.items + [room] %}
+ {% endif %}
+ {% endfor %}
+ {{ ns.items | to_json }}
+{%- endmacro %}
+
+{# Followers first; keep the fleet leader available to coordinate updates. #}
+{% macro satellite_devices() -%}
+ {{ [
+ {'key': 'upstairs-show', 'name': 'Upstairs Show', 'manage_volume': false, 'allow_idle_screen_off': true, 'node': 'upstairs_show', 'suffix': '', 'path': '/dashboard-kiosk-satellite-staging/echo-show5'},
+ {'key': 'office-show', 'name': 'Office Show', 'manage_volume': false, 'allow_idle_screen_off': true, 'node': 'office_show', 'suffix': '', 'path': '/dashboard-kiosk-satellite-staging/echo-show5'},
+ {'key': 'kitchen-show', 'name': 'Kitchen Show', 'manage_volume': true, 'allow_idle_screen_off': false, 'node': 'kitchen_show', 'suffix': '_2', 'path': '/dashboard-kiosk-satellite-staging/home'}
+ ] | to_json }}
+{%- endmacro %}
diff --git a/config/dashboards/infrastructure/partials/kiosk_sections.yaml b/config/dashboards/infrastructure/partials/kiosk_sections.yaml
index b1cd2e21..48f40ab8 100644
--- a/config/dashboards/infrastructure/partials/kiosk_sections.yaml
+++ b/config/dashboards/infrastructure/partials/kiosk_sections.yaml
@@ -22,7 +22,7 @@
columns: full
layout_type: custom:grid-layout
layout:
- grid-template-columns: repeat(2, minmax(0, 1fr))
+ grid-template-columns: repeat(4, minmax(0, 1fr))
grid-auto-flow: row
grid-auto-rows: min-content
grid-gap: 12px
@@ -70,12 +70,61 @@
action: navigate
navigation_path: /dashboard-kiosk-satellite-staging/home
+ - type: custom:button-card
+ template: bearstone_infra_status_kpi
+ entity: binary_sensor.upstairs_kiosk_needs_attention
+ name: Upstairs Show
+ icon: mdi:monitor-dashboard
+ variables:
+ ok_state: 'off'
+ critical_entity_ids:
+ - binary_sensor.upstairs_kiosk_needs_attention
+ warning_entity_ids:
+ - update.upstairs_show_update
+ healthy_text: ONLINE
+ warning_text: UPDATE AVAILABLE
+ alert_text: NEEDS ATTENTION
+ state_display: >
+ [[[
+ const updateAvailable = states['update.upstairs_show_update']?.state === 'on';
+ if (entity?.state === 'off') return updateAvailable ? 'UPDATE AVAILABLE' : 'ONLINE';
+ return String(entity?.attributes?.status ?? entity?.state ?? 'unknown').toUpperCase();
+ ]]]
+ tap_action:
+ action: navigate
+ navigation_path: /dashboard-kiosk-satellite-staging/echo-show5
+
+ - type: custom:button-card
+ template: bearstone_infra_status_kpi
+ entity: binary_sensor.office_kiosk_needs_attention
+ name: Office Show
+ icon: mdi:monitor-dashboard
+ variables:
+ ok_state: 'off'
+ critical_entity_ids:
+ - binary_sensor.office_kiosk_needs_attention
+ warning_entity_ids:
+ - update.office_show_update
+ healthy_text: ONLINE
+ warning_text: UPDATE AVAILABLE
+ alert_text: NEEDS ATTENTION
+ state_display: >
+ [[[
+ const updateAvailable = states['update.office_show_update']?.state === 'on';
+ if (entity?.state === 'off') return updateAvailable ? 'UPDATE AVAILABLE' : 'ONLINE';
+ return String(entity?.attributes?.status ?? entity?.state ?? 'unknown').toUpperCase();
+ ]]]
+ tap_action:
+ action: navigate
+ navigation_path: /dashboard-kiosk-satellite-staging/echo-show5
+
# -------------------------------------------------------------------
# Latest device screenshots
# -------------------------------------------------------------------
+# Native picture cards provide live device screenshots; button-card cannot render camera images.
- type: grid
column_span: 4
- columns: 2
+ columns: 4
square: false
cards:
- camera_view: auto
@@ -101,12 +150,36 @@
action: navigate
navigation_path: /dashboard-kiosk-satellite-staging/home
+ - camera_view: auto
+ type: picture-glance
+ title: Upstairs Show
+ image: https://demo.home-assistant.io/stub_config/kitchen.png
+ entities:
+ - entity: binary_sensor.upstairs_kiosk_needs_attention
+ - entity: sensor.upstairs_show_last_screenshot
+ camera_image: camera.upstairs_show_screenshot
+ tap_action:
+ action: navigate
+ navigation_path: /dashboard-kiosk-satellite-staging/echo-show5
+
+ - camera_view: auto
+ type: picture-glance
+ title: Office Show
+ image: https://demo.home-assistant.io/stub_config/kitchen.png
+ entities:
+ - entity: binary_sensor.office_kiosk_needs_attention
+ - entity: sensor.office_show_last_screenshot
+ camera_image: camera.office_show_screenshot
+ tap_action:
+ action: navigate
+ navigation_path: /dashboard-kiosk-satellite-staging/echo-show5
+
# -------------------------------------------------------------------
# Device telemetry
# -------------------------------------------------------------------
- type: grid
column_span: 4
- columns: 2
+ columns: 4
square: false
cards:
- type: custom:vertical-stack-in-card
@@ -199,3 +272,193 @@
name: Remote Admin
icon: mdi:remote-desktop
state_display: AVAILABLE
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: sensor.kitchen_show_voice_satellite
+ name: Voice Assistant
+ icon: mdi:microphone
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: select.kitchen_show_assistant
+ name: Assist Pipeline
+ icon: mdi:account-voice
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: select.kitchen_show_wake_word
+ name: Wake Word
+ icon: mdi:ear-hearing
+
+ - type: custom:vertical-stack-in-card
+ card_mod:
+ style: !include /config/dashboards/infrastructure/card_mod/infra_panel.yaml
+ cards:
+ - type: custom:button-card
+ template: bearstone_infra_panel_header
+ name: Upstairs Show
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: binary_sensor.upstairs_show_charging
+ name: Power
+ icon: mdi:power-plug
+ state_display: >
+ [[[ return states["binary_sensor.upstairs_show_charging"]?.state === "on" ? "PLUGGED IN" : "ON BATTERY"; ]]]
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: sensor.upstairs_show_last_seen
+ name: Last Seen
+ icon: mdi:clock-check-outline
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: sensor.upstairs_show_current_page
+ name: Dashboard
+ icon: mdi:web
+ state_display: >
+ [[[
+ const page = String(entity?.state ?? '');
+ return page.includes('/dashboard-kiosk-satellite-staging/echo-show5') ? 'PAGE OK' : 'CHECK PAGE';
+ ]]]
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: sensor.upstairs_show_foreground_app
+ name: Foreground App
+ icon: mdi:application-outline
+ state_display: >
+ [[[ return entity?.state === 'me.jxl.kiosk_satellite' ? 'KIOSK SATELLITE' : String(entity?.state ?? 'UNKNOWN').toUpperCase(); ]]]
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: light.upstairs_show_screen
+ name: Screen
+ icon: mdi:monitor
+ state_display: >
+ [[[ return String(entity?.state ?? 'UNKNOWN').toUpperCase(); ]]]
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: update.upstairs_show_update
+ name: Kiosk Satellite
+ icon: mdi:update
+ state_display: >
+ [[[
+ const installed = entity?.attributes?.installed_version ?? 'unknown';
+ const latest = entity?.attributes?.latest_version ?? installed;
+ return entity?.state === 'on' ? `UPDATE ${latest}` : `CURRENT ${installed}`;
+ ]]]
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: sensor.upstairs_show_remote_admin
+ name: Remote Admin
+ icon: mdi:remote-desktop
+ state_display: AVAILABLE
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: sensor.upstairs_show_voice_satellite
+ name: Voice Assistant
+ icon: mdi:microphone
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: select.upstairs_show_assistant
+ name: Assist Pipeline
+ icon: mdi:account-voice
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: select.upstairs_show_wake_word
+ name: Wake Word
+ icon: mdi:ear-hearing
+
+ - type: custom:vertical-stack-in-card
+ card_mod:
+ style: !include /config/dashboards/infrastructure/card_mod/infra_panel.yaml
+ cards:
+ - type: custom:button-card
+ template: bearstone_infra_panel_header
+ name: Office Show
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: binary_sensor.office_show_charging
+ name: Power
+ icon: mdi:power-plug
+ state_display: >
+ [[[ return states["binary_sensor.office_show_charging"]?.state === "on" ? "PLUGGED IN" : "ON BATTERY"; ]]]
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: sensor.office_show_last_seen
+ name: Last Seen
+ icon: mdi:clock-check-outline
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: sensor.office_show_current_page
+ name: Dashboard
+ icon: mdi:web
+ state_display: >
+ [[[
+ const page = String(entity?.state ?? '');
+ return page.includes('/dashboard-kiosk-satellite-staging/echo-show5') ? 'PAGE OK' : 'CHECK PAGE';
+ ]]]
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: sensor.office_show_foreground_app
+ name: Foreground App
+ icon: mdi:application-outline
+ state_display: >
+ [[[ return entity?.state === 'me.jxl.kiosk_satellite' ? 'KIOSK SATELLITE' : String(entity?.state ?? 'UNKNOWN').toUpperCase(); ]]]
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: light.office_show_screen
+ name: Screen
+ icon: mdi:monitor
+ state_display: >
+ [[[ return String(entity?.state ?? 'UNKNOWN').toUpperCase(); ]]]
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: update.office_show_update
+ name: Kiosk Satellite
+ icon: mdi:update
+ state_display: >
+ [[[
+ const installed = entity?.attributes?.installed_version ?? 'unknown';
+ const latest = entity?.attributes?.latest_version ?? installed;
+ return entity?.state === 'on' ? `UPDATE ${latest}` : `CURRENT ${installed}`;
+ ]]]
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: sensor.office_show_remote_admin
+ name: Remote Admin
+ icon: mdi:remote-desktop
+ state_display: AVAILABLE
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: sensor.office_show_voice_satellite
+ name: Voice Assistant
+ icon: mdi:microphone
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: select.office_show_assistant
+ name: Assist Pipeline
+ icon: mdi:account-voice
+
+ - type: custom:button-card
+ template: bearstone_infra_list_row
+ entity: select.office_show_wake_word
+ name: Wake Word
+ icon: mdi:ear-hearing
diff --git a/config/dashboards/kiosk_satellite_staging/dashboard.yaml b/config/dashboards/kiosk_satellite_staging/dashboard.yaml
index 9c397e98..d6e1afd0 100644
--- a/config/dashboards/kiosk_satellite_staging/dashboard.yaml
+++ b/config/dashboards/kiosk_satellite_staging/dashboard.yaml
@@ -13,4 +13,5 @@
######################################################################
title: Kiosk Satellite Preview
+button_card_templates: !include /config/dashboards/kiosk_satellite_staging/templates.yaml
views: !include_dir_list /config/dashboards/kiosk_satellite_staging/views
diff --git a/config/dashboards/kiosk_satellite_staging/partials/event_overlays.yaml b/config/dashboards/kiosk_satellite_staging/partials/event_overlays.yaml
new file mode 100644
index 00000000..b1bdd76c
--- /dev/null
+++ b/config/dashboards/kiosk_satellite_staging/partials/event_overlays.yaml
@@ -0,0 +1,173 @@
+######################################################################
+# @CCOSTAN - Follow Me on X
+# For more info visit https://www.vcloudinfo.com/click-here
+# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
+# -------------------------------------------------------------------
+# Shared Kiosk Events - Family trails, NOAA radar, and Driveway takeover
+# Native Garage and Front Door camera overlays keep higher priority.
+# Notes: NOAA radar uses the supported OSM basemap to avoid CARTO API-key watermarks.
+######################################################################
+
+# Core map cards are required because only HA's history subscription can render Recorder-backed trails.
+- type: conditional
+ conditions:
+ - &kitchen_family_detail_timer_active
+ entity: timer.kitchen_kiosk_detail_display
+ state: active
+ - &kiosk_no_camera_event
+ entity: sensor.kiosk_active_camera_view
+ state: none
+ - &kiosk_no_radar_event
+ entity: binary_sensor.kiosk_rain_radar_active
+ state: "off"
+ - entity: input_text.kitchen_kiosk_detail_view
+ state: "family|person.carlo"
+ card:
+ type: map
+ entities:
+ - person.carlo
+ hours_to_show: 24
+ theme_mode: dark
+ card_mod:
+ style: &kitchen_family_history_map_style |
+ ha-card {
+ position: fixed !important;
+ inset: 0 !important;
+ z-index: 9999 !important;
+ width: 100vw !important;
+ height: 100vh !important;
+ margin: 0 !important;
+ border: 0 !important;
+ border-radius: 0 !important;
+ background: #03070b !important;
+ pointer-events: auto !important;
+ }
+ #root {
+ height: 100vh !important;
+ padding-bottom: 0 !important;
+ }
+ #buttons {
+ top: 145px !important;
+ }
+
+- type: conditional
+ conditions:
+ - *kitchen_family_detail_timer_active
+ - *kiosk_no_camera_event
+ - *kiosk_no_radar_event
+ - entity: input_text.kitchen_kiosk_detail_view
+ state: "family|person.stacey"
+ card:
+ type: map
+ entities:
+ - person.stacey
+ hours_to_show: 24
+ theme_mode: dark
+ card_mod:
+ style: *kitchen_family_history_map_style
+
+- type: conditional
+ conditions:
+ - *kitchen_family_detail_timer_active
+ - *kiosk_no_camera_event
+ - *kiosk_no_radar_event
+ - entity: input_text.kitchen_kiosk_detail_view
+ state: "family|person.justin"
+ card:
+ type: map
+ entities:
+ - person.justin
+ hours_to_show: 24
+ theme_mode: dark
+ card_mod:
+ style: *kitchen_family_history_map_style
+
+- type: conditional
+ conditions:
+ - *kitchen_family_detail_timer_active
+ - *kiosk_no_camera_event
+ - *kiosk_no_radar_event
+ - entity: input_text.kitchen_kiosk_detail_view
+ state: "family|person.paige"
+ card:
+ type: map
+ entities:
+ - person.paige
+ hours_to_show: 24
+ theme_mode: dark
+ card_mod:
+ style: *kitchen_family_history_map_style
+
+# NOAA is the single radar path; native Kiosk Satellite camera overlays retain priority.
+- type: conditional
+ conditions:
+ - entity: binary_sensor.kiosk_rain_radar_active
+ state: "on"
+ card:
+ type: custom:weather-radar-card
+ data_source: NOAA
+ map_style: OSM
+ zoom_level: 7
+ past_minutes: 20
+ frame_stride_minutes: 5
+ frame_delay: 650
+ restart_delay: 1400
+ smooth_animation: true
+ smooth_overlap: 0
+ motion_compensation: false
+ static_map: true
+ double_tap_action: none
+ show_zoom: false
+ show_playback: false
+ show_recenter: false
+ show_scale: true
+ show_range: false
+ show_color_bar: true
+ show_progress_bar: true
+ show_loading_spinner: true
+ show_alerts: false
+ show_lightning: false
+ height: 100vh
+ width: 100%
+ card_mod:
+ style: |
+ ha-card {
+ position: fixed !important;
+ inset: 0 !important;
+ width: 100vw !important;
+ height: 100vh !important;
+ margin: 0 !important;
+ border: 0 !important;
+ border-radius: 0 !important;
+ z-index: 9999 !important;
+ pointer-events: auto !important;
+ background: #03070b !important;
+ }
+
+# A core conditional picture card is required for the Driveway source,
+# which is not configured as a native Kiosk Satellite camera shortcut.
+- type: conditional
+ conditions:
+ - entity: sensor.kiosk_active_camera_view
+ state: driveway
+ card:
+ type: picture-glance
+ title: Driveway Alert
+ camera_image: camera.driveway
+ camera_view: live
+ aspect_ratio: "16:10"
+ entities: []
+ card_mod:
+ style: |
+ ha-card {
+ position: fixed !important;
+ inset: 0 !important;
+ width: 100vw !important;
+ height: 100vh !important;
+ margin: 0 !important;
+ border: 0 !important;
+ border-radius: 0 !important;
+ z-index: 9999 !important;
+ pointer-events: auto !important;
+ background: #03070b !important;
+ }
diff --git a/config/dashboards/kiosk_satellite_staging/partials/home_sections.yaml b/config/dashboards/kiosk_satellite_staging/partials/home_sections.yaml
index c0fb14da..7253f324 100644
--- a/config/dashboards/kiosk_satellite_staging/partials/home_sections.yaml
+++ b/config/dashboards/kiosk_satellite_staging/partials/home_sections.yaml
@@ -17,6 +17,7 @@
# Notes: Nearby rain or lightning starts a five-minute NOAA radar view only while the sun is above the horizon.
# Notes: After radar returns to the ambient wall, Storms Approaching offers another five-minute replay from the lower banner.
# Notes: Actionable banner slides open one shared full-screen detail surface and return automatically to the ambient wall.
+# Notes: The idle event layer passes taps through to the banner; active overlay cards handle their own input.
# Notes: Family detail centers only the selected person and includes the prior 24-hour trail; Phyn detail includes a confirmed main-valve restore action.
# Notes: Rheem problem telemetry adds a red/amber water-heater slide and enriches the existing hot-water detail.
# Notes: New LeagueSecretary results add Carlo and Stacey slides for 48 hours; other bowlers stay out of the kiosk payload.
@@ -36,6 +37,10 @@
grid_options:
columns: full
layout_type: custom:grid-layout
+ card_mod:
+ style:
+ "grid-layout $": |
+ hui-vertical-stack-card { pointer-events: none !important; }
layout:
grid-template-columns: repeat(2, minmax(0, 1fr))
grid-template-areas: |
@@ -164,1184 +169,13 @@
view_layout:
grid-area: frontlawn
- # CSS animation rotates only the currently relevant, read-only household facts.
+ # Shared alert semantics; default presentation preserves the Kitchen wall.
- type: custom:button-card
- show_icon: false
- show_name: false
- show_state: false
- tap_action:
- action: none
- hold_action:
- action: none
- double_tap_action:
- action: none
- triggers_update:
- - binary_sensor.garage_phyn_battery_alert
- - binary_sensor.garage_phyn_freeze_warning_alert
- - binary_sensor.garage_phyn_high_pressure_alert
- - binary_sensor.garage_phyn_leak_alert
- - binary_sensor.garage_phyn_offline_leak_shutoff_alert
- - binary_sensor.garage_phyn_pinhole_leak_alert
- - binary_sensor.garage_phyn_recurring_flow_alert
- - binary_sensor.garage_phyn_temperature_alert
- - valve.phyn_shutoff_valve
- - sensor.phyn_current_water_pressure
- - sensor.phyn_average_water_flow_rate
- - sensor.nws_alerts_alerts
- - group.garage_doors
- - group.entry_points
- - group.locks
- - binary_sensor.front_door_packages_present
- - sensor.rheem_wh_available_hot_water
- - sensor.rheem_wh_alert_count
- - sensor.rheem_wh_diagnostic_status
- - binary_sensor.rheem_wh_problem
- - binary_sensor.rheem_wh_active_alert
- - binary_sensor.rheem_wh_telemetry_stale
- - binary_sensor.rheem_wh_running
- - sensor.rheem_wh_tank_health
- - sensor.rheem_wh_compressor_health
- - water_heater.rheem_wh
- - light.sink
- - input_boolean.garbage_cans_out
- - input_datetime.water_delivery_date
- - sensor.water_softener_salt_days_since_last_add
- - sensor.water_softener_salt_average_days_between_refills
- - sensor.spaceship_battery_level
- - switch.spaceship_charge
- - input_datetime.tesla_model_y_last_garage_close
- - input_datetime.tesla_model_y_plug_in_snooze_until
- - sensor.next_booked_cruise_countdown
- - sensor.bearclaw_status_telemetry
- - sensor.kiosk_recent_personal_records
- - person.carlo
- - person.stacey
- - person.justin
- - person.paige
- - sensor.carlo_location_display
- - sensor.stacey_location_display
- - sensor.justin_location_display
- - sensor.paige_location_display
- - binary_sensor.kiosk_rain_radar_weather_event
- - binary_sensor.kiosk_rain_radar_active
- - timer.kitchen_kiosk_radar_display
- - input_text.kitchen_kiosk_detail_view
- - timer.kitchen_kiosk_detail_display
- - camera.frontdoorbell
- custom_fields:
- ambient: >
- [[[
- const state = (entityId) => states[entityId]?.state;
- const valid = (value) => !['unknown', 'unavailable', 'none', '', undefined].includes(value);
- const pretty = (value) => String(value || 'Unknown').replaceAll('_', ' ');
- const dateKey = (date) => {
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- return `${year}-${month}-${day}`;
- };
- const items = [];
- const now = new Date();
- const detailValue = valid(state('input_text.kitchen_kiosk_detail_view'))
- ? state('input_text.kitchen_kiosk_detail_view')
- : 'none';
- const [detailType, detailSubject = ''] = detailValue.split('|');
- const detailActive = state('timer.kitchen_kiosk_detail_display') === 'active' && detailType !== 'none';
- const callScript = (service, data = {}) =>
- hass.callService('script', service, data);
- const closeDetail = (event) => {
- event?.stopPropagation();
- hass.callService('timer', 'cancel', {}, {
- entity_id: 'timer.kitchen_kiosk_detail_display'
- });
- };
-
- const radarWeatherEvent = states['binary_sensor.kiosk_rain_radar_weather_event'];
- const radarDisplayTimer = states['timer.kitchen_kiosk_radar_display'];
- const radarReplayReady =
- radarWeatherEvent?.state === 'on' &&
- state('binary_sensor.kiosk_rain_radar_active') !== 'on' &&
- radarDisplayTimer?.state === 'idle' &&
- new Date(radarDisplayTimer.last_changed).getTime() >
- new Date(radarWeatherEvent.last_changed).getTime();
- if (radarReplayReady) {
- items.push({
- kicker: 'WEATHER RADAR',
- title: 'Storms Approaching',
- detail: 'Tap for radar',
- icon: 'mdi:radar',
- accent: '#0288d1',
- action: {
- service: 'kitchen_kiosk_show_event',
- data: {event_type: 'radar'}
- }
- });
- }
-
- const phynAlerts = [
- 'binary_sensor.garage_phyn_battery_alert',
- 'binary_sensor.garage_phyn_freeze_warning_alert',
- 'binary_sensor.garage_phyn_high_pressure_alert',
- 'binary_sensor.garage_phyn_leak_alert',
- 'binary_sensor.garage_phyn_offline_leak_shutoff_alert',
- 'binary_sensor.garage_phyn_pinhole_leak_alert',
- 'binary_sensor.garage_phyn_recurring_flow_alert',
- 'binary_sensor.garage_phyn_temperature_alert'
- ].filter((entityId) => state(entityId) === 'on');
- const phynValveState = state('valve.phyn_shutoff_valve');
- if (phynAlerts.length > 0 || (valid(phynValveState) && phynValveState !== 'open')) {
- const alertNames = phynAlerts.map((entityId) =>
- states[entityId]?.attributes?.friendly_name || pretty(entityId.split('.')[1])
- );
- items.push({
- kicker: 'WATER SYSTEM',
- title: phynValveState === 'closed' ? 'Main water is off' : 'Phyn needs attention',
- detail: `${alertNames.join(' · ') || `Valve is ${pretty(phynValveState)}`} · Tap for water details`,
- icon: 'mdi:water-alert-outline',
- accent: '#c62828',
- action: {
- service: 'kitchen_kiosk_show_detail',
- data: {detail_type: 'phyn'}
- }
- });
- }
-
- const weatherAlertEntity = states['sensor.nws_alerts_alerts'];
- const weatherAlertCount = Number.parseInt(weatherAlertEntity?.state, 10);
- const weatherAlerts = Array.isArray(weatherAlertEntity?.attributes?.Alerts)
- ? weatherAlertEntity.attributes.Alerts
- : [];
- const cleanAlertText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
- const alertSection = (description, label) => {
- const pattern = new RegExp(
- `\\*\\s*${label}\\.{3}\\s*([\\s\\S]*?)(?=\\n\\s*\\n\\s*\\*\\s*[A-Z ]+\\.{3}|$)`,
- 'i'
- );
- return cleanAlertText(String(description || '').match(pattern)?.[1]);
- };
- const limitAlertText = (value, maxLength = 240) => {
- const text = cleanAlertText(value);
- if (text.length <= maxLength) return text;
- return `${text.slice(0, maxLength - 3).replace(/\s+\S*$/, '')}...`;
- };
- const severityOrder = { Extreme: 0, Severe: 1, Moderate: 2, Minor: 3, Unknown: 4 };
- const severityColors = {
- Extreme: '#b71c1c',
- Severe: '#c62828',
- Moderate: '#d84315',
- Minor: '#f9a825',
- Unknown: '#546e7a'
- };
- const uniqueWeatherAlerts = weatherAlerts
- .filter((alert, index, alerts) => {
- const alertKey = alert.ID || alert.URL || `${alert.Event}|${alert.Expires}`;
- return alerts.findIndex((candidate) =>
- (candidate.ID || candidate.URL || `${candidate.Event}|${candidate.Expires}`) === alertKey
- ) === index;
- })
- .sort((left, right) =>
- (severityOrder[left.Severity] ?? severityOrder.Unknown)
- - (severityOrder[right.Severity] ?? severityOrder.Unknown)
- );
-
- if (uniqueWeatherAlerts.length > 0) {
- uniqueWeatherAlerts.forEach((alert, index) => {
- const severity = severityColors[alert.Severity] ? alert.Severity : 'Unknown';
- const detailParts = [
- alertSection(alert.Description, 'WHAT'),
- alertSection(alert.Description, 'WHERE'),
- alertSection(alert.Description, 'WHEN')
- ].filter(Boolean);
- const detail = detailParts.length > 0
- ? detailParts.join(' · ')
- : alert.Headline || alert.Description || 'Active National Weather Service alert';
- const position = uniqueWeatherAlerts.length > 1
- ? ` ${index + 1}/${uniqueWeatherAlerts.length}`
- : '';
- items.push({
- kicker: `WEATHER ALERT${position} · ${severity.toUpperCase()}`,
- title: alert.Event || `${weatherAlertCount} active alerts`,
- detail: limitAlertText(detail),
- icon: 'mdi:weather-lightning-rainy',
- accent: severityColors[severity],
- action: {
- service: 'kitchen_kiosk_show_detail',
- data: {
- detail_type: 'weather',
- detail_subject: String(alert.ID || index)
- }
- }
- });
- });
- } else if (Number.isFinite(weatherAlertCount) && weatherAlertCount > 0) {
- items.push({
- kicker: 'WEATHER ALERT',
- title: `${weatherAlertCount} active ${weatherAlertCount === 1 ? 'alert' : 'alerts'}`,
- detail: 'Alert details are temporarily unavailable',
- icon: 'mdi:weather-lightning-rainy',
- accent: severityColors.Unknown,
- action: {
- service: 'kitchen_kiosk_show_detail',
- data: {detail_type: 'weather', detail_subject: '0'}
- }
- });
- }
-
- if (valid(state('group.garage_doors')) && !['closed', 'off'].includes(state('group.garage_doors'))) {
- const openGarages = ['cover.large_garage_door', 'cover.small_garage_door']
- .filter((entityId) => valid(state(entityId)) && state(entityId) !== 'closed')
- .map((entityId) => states[entityId]?.attributes?.friendly_name || pretty(entityId.split('.')[1]));
- items.push({
- kicker: 'GARAGE',
- title: 'Garage door open',
- detail: openGarages.join(' · ') || 'Check both garage doors',
- icon: 'mdi:garage-open-variant',
- accent: '#ef6c00'
- });
- }
-
- if (state('group.entry_points') === 'on') {
- const openEntries = (states['group.entry_points']?.attributes?.entity_id || [])
- .filter((entityId) => state(entityId) === 'on')
- .map((entityId) => states[entityId]?.attributes?.friendly_name || pretty(entityId.split('.')[1]));
- const extraCount = Math.max(0, openEntries.length - 3);
- const entryDetail = openEntries.slice(0, 3).join(' · ') + (extraCount ? ` · +${extraCount} more` : '');
- items.push({
- kicker: 'ENTRY POINT',
- title: openEntries.length === 1 ? 'Door or window open' : `${openEntries.length} openings detected`,
- detail: entryDetail || 'Check the exterior doors and windows',
- icon: 'mdi:door-open',
- accent: '#ef6c00',
- action: {
- service: 'kitchen_kiosk_show_detail',
- data: {detail_type: 'entry_points'}
- }
- });
- }
-
- if (valid(state('group.locks')) && state('group.locks') !== 'locked') {
- items.push({
- kicker: 'SECURITY',
- title: 'Check the front door lock',
- detail: `Lock group is ${pretty(state('group.locks'))}`,
- icon: 'mdi:lock-alert-outline',
- accent: '#c62828'
- });
- }
-
- if (state('binary_sensor.front_door_packages_present') === 'on') {
- items.push({
- kicker: 'FRONT DOOR',
- title: 'Package waiting',
- detail: 'Tap for the front door view',
- icon: 'mdi:package-variant-closed',
- accent: '#8e5a2b',
- action: {
- service: 'kitchen_kiosk_show_detail',
- data: {detail_type: 'package'}
- }
- });
- }
-
- const rheemAlertCount = Number.parseInt(state('sensor.rheem_wh_alert_count'), 10);
- const rheemStatus = state('sensor.rheem_wh_diagnostic_status');
- const rheemProblem = state('binary_sensor.rheem_wh_problem') === 'on';
- if (rheemProblem) {
- const activeAlert = state('binary_sensor.rheem_wh_active_alert') === 'on';
- const telemetryStale = state('binary_sensor.rheem_wh_telemetry_stale') === 'on';
- items.push({
- kicker: telemetryStale && !activeAlert ? 'WATER HEATER TELEMETRY' : 'WATER HEATER ALERT',
- title: activeAlert && Number.isFinite(rheemAlertCount)
- ? `${rheemAlertCount} active ${rheemAlertCount === 1 ? 'alert' : 'alerts'}`
- : `Rheem status: ${pretty(rheemStatus)}`,
- detail: telemetryStale
- ? 'EcoNet data has been unavailable for 10 minutes · Tap for diagnostics'
- : 'Home Assistant Repairs has the diagnostic snapshot · Tap for details',
- icon: 'mdi:water-boiler-alert',
- accent: telemetryStale && !activeAlert ? '#ef6c00' : '#c62828',
- action: {
- service: 'kitchen_kiosk_show_detail',
- data: {detail_type: 'hot_water'}
- }
- });
- }
-
- const hotWater = Number.parseFloat(state('sensor.rheem_wh_available_hot_water'));
- if (!rheemProblem && Number.isFinite(hotWater) && hotWater < 30) {
- items.push({
- kicker: 'HOT WATER',
- title: 'Hot water is running low',
- detail: `${Math.round(hotWater)}% available · Tap for recovery details`,
- icon: 'mdi:water-thermometer-outline',
- accent: '#0277bd',
- action: {
- service: 'kitchen_kiosk_show_detail',
- data: {detail_type: 'hot_water'}
- }
- });
- }
-
- const weekday = now.toLocaleDateString('en-US', {weekday: 'short'});
- if (['Wed', 'Sun'].includes(weekday) && state('input_boolean.garbage_cans_out') !== 'on') {
- items.push({
- kicker: 'TODAY',
- title: weekday === 'Wed' ? 'Garbage + recycling day' : 'Garbage day',
- detail: 'Put the cans at the curb',
- icon: 'mdi:trash-can-outline',
- accent: '#2e7d32'
- });
- }
-
- const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
- if (state('input_datetime.water_delivery_date') === dateKey(tomorrow)) {
- items.push({
- kicker: 'WATER DELIVERY TOMORROW',
- title: 'Put the bottles out',
- detail: 'Stage them before leaving the house',
- icon: 'mdi:bottle-soda-classic-outline',
- accent: '#0277bd'
- });
- }
-
- const battery = Number.parseFloat(state('sensor.spaceship_battery_level'));
- const lastClose = new Date(String(state('input_datetime.tesla_model_y_last_garage_close')).replace(' ', 'T'));
- const snoozeUntil = new Date(String(state('input_datetime.tesla_model_y_plug_in_snooze_until')).replace(' ', 'T'));
- const recentArrival = Number.isFinite(lastClose.getTime()) && (now.getTime() - lastClose.getTime()) < 43200000;
- const snoozeExpired = !Number.isFinite(snoozeUntil.getTime()) || snoozeUntil <= now;
- if (
- Number.isFinite(battery) && battery < 50 && recentArrival && snoozeExpired &&
- state('switch.spaceship_charge') === 'off' &&
- state('person.carlo') === 'home' && state('person.stacey') === 'home'
- ) {
- items.push({
- kicker: 'MODEL Y',
- title: 'Plug in the Tesla',
- detail: `${Math.round(battery)}% battery after the latest garage arrival`,
- icon: 'mdi:ev-plug-tesla',
- accent: '#c62828'
- });
- }
-
- if (state('light.sink') === 'unavailable') {
- items.push({
- kicker: 'CHECK WALL SWITCH',
- title: 'Kitchen sink light',
- detail: 'Please turn its physical switch back on.',
- icon: 'mdi:lightbulb-alert-outline',
- accent: '#ef6c00'
- });
- }
-
- const saltDays = Number.parseFloat(state('sensor.water_softener_salt_days_since_last_add'));
- const saltAverage = Number.parseFloat(state('sensor.water_softener_salt_average_days_between_refills'));
- if (
- Number.isFinite(saltDays) && Number.isFinite(saltAverage) && saltAverage > 0 &&
- saltDays >= saltAverage * 0.9
- ) {
- items.push({
- kicker: 'WATER SOFTENER',
- title: 'Salt refill coming due',
- detail: `${Math.round(saltDays)} days since the last refill`,
- icon: 'mdi:shaker-outline',
- accent: '#00838f'
- });
- }
-
- const bowlingHighlights = states['sensor.bearclaw_status_telemetry']?.attributes?.bowlingHighlights;
- if (bowlingHighlights?.active && Array.isArray(bowlingHighlights.items)) {
- bowlingHighlights.items.forEach((highlight) => {
- const expiresAt = new Date(highlight?.expiresAt || '');
- const games = (Array.isArray(highlight?.games) ? highlight.games : [])
- .map((score) => Number.parseInt(score, 10))
- .filter(Number.isFinite);
- const highGame = Number.parseInt(highlight?.highGame, 10);
- const seriesTotal = Number.parseInt(highlight?.seriesTotal, 10);
- if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= now || !games.length || !Number.isFinite(highGame)) return;
- const records = new Set(Array.isArray(highlight?.personalBestIds) ? highlight.personalBestIds : []);
- const highGameRecord = records.has('bowling_high_game');
- const highSeriesRecord = records.has('bowling_high_series') && Number.isFinite(seriesTotal);
- const title = highGameRecord
- ? `${highlight.displayName}'s new high game: ${highGame}`
- : (highSeriesRecord
- ? `${highlight.displayName}'s new high series: ${seriesTotal}`
- : `${highlight.displayName}'s high game: ${highGame}`);
- const detail = [
- games.join(' / '),
- Number.isFinite(seriesTotal) ? `${seriesTotal} series` : '',
- Number.isFinite(Number(highlight?.weekNum)) ? `Week ${highlight.weekNum}` : ''
- ].filter(Boolean).join(' · ');
- items.push({
- kicker: highGameRecord || highSeriesRecord ? 'BOWLING PERSONAL BEST' : 'TUESDAY BOWLING',
- title,
- detail,
- icon: 'mdi:bowling',
- accent: '#7e57c2'
- });
- });
- }
-
- const recordFeed = states['sensor.kiosk_recent_personal_records'];
- const personalRecords = recordFeed?.state === 'ready' ? recordFeed.attributes?.items : null;
- if (Array.isArray(personalRecords)) {
- personalRecords.forEach((record) => {
- const occurredAt = new Date(record?.occurredAt || '');
- if (!Number.isFinite(occurredAt.getTime()) ||
- occurredAt > now || now - occurredAt > 7 * 24 * 60 * 60 * 1000 ||
- !record?.person || !record?.label || !record?.displayValue) return;
- items.push({
- kicker: 'NEW PERSONAL BEST',
- title: `${record.person}: ${record.label}`,
- detail: [record.displayValue, record.activityType, record.dayKey].filter(Boolean).join(' · '),
- icon: 'mdi:trophy-outline',
- accent: '#2e7d32'
- });
- });
- }
-
- const cruiseDays = Number.parseInt(state('sensor.next_booked_cruise_countdown'), 10);
- const cruiseEntity = states['sensor.next_booked_cruise_countdown'];
- const cruiseShip = String(cruiseEntity?.attributes?.ship || '').trim();
- const cruiseDate = String(cruiseEntity?.attributes?.sail_date || '').trim();
- if (Number.isInteger(cruiseDays) && cruiseDays >= 0 && cruiseShip && cruiseDate) {
- const sailDate = new Date(`${cruiseDate}T12:00:00`);
- const sailLabel = Number.isFinite(sailDate.getTime())
- ? sailDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
- : cruiseDate;
- const cruiseNights = Number.parseInt(cruiseEntity?.attributes?.nights, 10);
- const cruisePort = String(cruiseEntity?.attributes?.port || '').trim();
- const detail = [
- `Departs ${sailLabel}`,
- Number.isInteger(cruiseNights) && cruiseNights > 0
- ? `${cruiseNights} ${cruiseNights === 1 ? 'night' : 'nights'}`
- : '',
- cruisePort
- ].filter(Boolean).join(' · ');
- items.push({
- kicker: 'NEXT BOOKED CRUISE',
- title: cruiseDays === 0
- ? `${cruiseShip} sails today`
- : `${cruiseDays} ${cruiseDays === 1 ? 'day' : 'days'} until ${cruiseShip}`,
- detail,
- icon: 'mdi:ferry',
- accent: '#00838f'
- });
- }
-
- [
- ['Carlo', 'person.carlo', 'sensor.carlo_location_display'],
- ['Stacey', 'person.stacey', 'sensor.stacey_location_display'],
- ['Justin', 'person.justin', 'sensor.justin_location_display'],
- ['Paige', 'person.paige', 'sensor.paige_location_display']
- ].forEach(([name, personId, locationId]) => {
- const personState = state(personId);
- if (valid(personState) && personState !== 'home') {
- const locationEntity = states[locationId];
- const rawLocation = pretty(valid(state(locationId)) ? state(locationId) : personState);
- const withoutName = rawLocation.replace(new RegExp(`^${name}\\s+`, 'i'), '');
- const sinceMatch = withoutName.match(/\s*\(since\s+([^)]+)\)\s*$/i);
- const location = withoutName.replace(/\s*\(since\s+[^)]+\)\s*$/i, '').trim();
- const changedAt = new Date(locationEntity?.last_changed || states[personId]?.last_changed);
- const since = sinceMatch?.[1] || (
- Number.isFinite(changedAt.getTime())
- ? changedAt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
- : 'recently'
- );
- items.push({
- kicker: 'FAMILY LOCATION',
- title: `${name} - ${location || pretty(personState)}`,
- detail: `Since ${since}`,
- icon: 'mdi:map-marker-account-outline',
- accent: '#6a1b9a',
- action: {
- service: 'kitchen_kiosk_show_detail',
- data: {detail_type: 'family', detail_subject: personId}
- }
- });
- }
- });
-
- if (items.length === 0) {
- items.push({
- kicker: 'HOUSEHOLD',
- title: 'Everything is handled',
- detail: 'Everyone is home and there are no active reminders',
- icon: 'mdi:check-circle-outline',
- accent: '#2e7d32'
- });
- }
-
- const formatSince = (entity) => {
- const changed = new Date(entity?.last_changed);
- if (!Number.isFinite(changed.getTime())) return 'recently';
- const minutes = Math.max(0, Math.floor((Date.now() - changed.getTime()) / 60000));
- if (minutes < 1) return 'just now';
- if (minutes < 60) return `${minutes} min ago`;
- const hours = Math.floor(minutes / 60);
- return `${hours} hr${hours === 1 ? '' : 's'} ago`;
- };
- const renderDetail = () => {
- let kicker = 'HOUSEHOLD DETAIL';
- let title = 'Current status';
- let icon = 'mdi:information-outline';
- let body = html`
This detail is no longer active.
`;
-
- if (detailType === 'weather') {
- const alert = uniqueWeatherAlerts.find((candidate, index) =>
- String(candidate.ID || index) === detailSubject
- ) || uniqueWeatherAlerts[0];
- kicker = `NWS WEATHER ALERT · ${String(alert?.Severity || 'Unknown').toUpperCase()}`;
- title = alert?.Event || 'Weather alert';
- icon = 'mdi:weather-lightning-rainy';
- if (alert) {
- const sections = [
- ['WHAT', alertSection(alert.Description, 'WHAT')],
- ['WHERE', alertSection(alert.Description, 'WHERE')],
- ['WHEN', alertSection(alert.Description, 'WHEN')],
- ['IMPACTS', alertSection(alert.Description, 'IMPACTS')]
- ].filter(([, value]) => value);
- const instructions = limitAlertText(alert.Instruction, 220);
- body = html`
- ${cleanAlertText(alert.Headline)}
-
- ${sections.map(([label, value]) => html`
-
- `)}
-
- ${instructions ? html`
-
- STAY SAFE · ${instructions}
-
- ` : ''}
- `;
- }
- } else if (detailType === 'package') {
- const camera = states['camera.frontdoorbell'];
- const packageEntity = states['binary_sensor.front_door_packages_present'];
- const picture = String(camera?.attributes?.entity_picture || '');
- kicker = 'FRONT DOOR';
- title = 'Package waiting';
- icon = 'mdi:package-variant-closed';
- body = html`
-
- ${picture ? html`
` : ''}
-
- Last checked ${packageEntity?.attributes?.last_run || 'recently'}
-
-
- `;
- } else if (detailType === 'family') {
- const person = states[detailSubject];
- const locationEntityId = {
- 'person.carlo': 'sensor.carlo_location_display',
- 'person.stacey': 'sensor.stacey_location_display',
- 'person.justin': 'sensor.justin_location_display',
- 'person.paige': 'sensor.paige_location_display'
- }[detailSubject];
- const personName = person?.attributes?.friendly_name || pretty(detailSubject.split('.')[1]);
- const rawLocation = pretty(state(locationEntityId));
- const withoutName = rawLocation.replace(new RegExp(`^${personName}\s+`, 'i'), '');
- const location = withoutName.replace(/\s*\(since\s+[^)]+\)\s*$/i, '').trim();
- kicker = 'FAMILY LOCATION';
- title = `${personName} · ${location || pretty(person?.state)}`;
- icon = 'mdi:map-marker-account-outline';
- body = html`Updated ${formatSince(person)} · 24-hour trail
`;
- } else if (detailType === 'entry_points') {
- const openEntries = (states['group.entry_points']?.attributes?.entity_id || [])
- .map((entityId) => states[entityId])
- .filter((entry) => entry?.state === 'on');
- kicker = 'ENTRY POINTS';
- title = `${openEntries.length} ${openEntries.length === 1 ? 'opening' : 'openings'} detected`;
- icon = 'mdi:door-open';
- body = html`
-
- ${openEntries.map((entry) => html`
-
-
-
-
${entry.attributes?.friendly_name || 'Open entry'}
-
Open ${formatSince(entry)}
-
-
- `)}
-
- `;
- } else if (detailType === 'phyn') {
- const valveState = state('valve.phyn_shutoff_valve');
- const flow = Number.parseFloat(state('sensor.phyn_average_water_flow_rate'));
- const pressure = Number.parseFloat(state('sensor.phyn_current_water_pressure'));
- const alerts = phynAlerts.map((entityId) =>
- states[entityId]?.attributes?.friendly_name || pretty(entityId.split('.')[1])
- );
- kicker = 'PHYN WATER SYSTEM';
- title = valveState === 'closed' ? 'Main water is off' : 'Water system needs attention';
- icon = 'mdi:water-alert-outline';
- body = html`
-
-
- MAIN VALVE ${pretty(valveState)}
-
-
- FLOW ${Number.isFinite(flow) ? `${flow.toFixed(1)} gal/min` : 'Unknown'}
-
-
- PRESSURE ${Number.isFinite(pressure) ? `${pressure.toFixed(0)} psi` : 'Unknown'}
-
-
-
- ${alerts.length ? alerts.join(' · ') : 'No active Phyn alert text is available.'}
-
- ${valveState === 'closed' ? html`
- {
- event.stopPropagation();
- if (window.confirm('Turn the Phyn main water supply ON? Confirm the leak is safe first.')) {
- callScript('kitchen_kiosk_restore_phyn_water');
- }
- }}
- >TURN WATER ON
- ` : html`Main water is ${pretty(valveState)}
`}
- `;
- } else if (detailType === 'hot_water') {
- const available = Number.parseFloat(state('sensor.rheem_wh_available_hot_water'));
- const alertCount = Number.parseInt(state('sensor.rheem_wh_alert_count'), 10);
- const diagnosticStatus = state('sensor.rheem_wh_diagnostic_status');
- const rheemProblem = state('binary_sensor.rheem_wh_problem') === 'on';
- const heater = states['water_heater.rheem_wh'];
- const running = state('binary_sensor.rheem_wh_running') === 'on';
- const target = Number.parseFloat(heater?.attributes?.temperature);
- const mode = pretty(heater?.attributes?.operation_mode || heater?.state);
- const tankHealth = Number.parseFloat(state('sensor.rheem_wh_tank_health'));
- const compressorHealth = Number.parseFloat(state('sensor.rheem_wh_compressor_health'));
- kicker = rheemProblem ? 'WATER HEATER ALERT' : 'HOT WATER RECOVERY';
- title = rheemProblem && Number.isFinite(alertCount) && alertCount > 0
- ? `${alertCount} active ${alertCount === 1 ? 'alert' : 'alerts'}`
- : (running ? 'Heating now' : 'Standing by');
- icon = rheemProblem ? 'mdi:water-boiler-alert' : 'mdi:water-boiler';
- body = html`
-
-
${Number.isFinite(available) ? Math.round(available) : '--'}%
-
HOT WATER AVAILABLE
-
-
MODE ${mode}
-
TARGET ${Number.isFinite(target) ? `${Math.round(target)}°F` : 'Unknown'}
-
TANK HEALTH ${Number.isFinite(tankHealth) ? `${Math.round(tankHealth)}%` : 'Unknown'}
-
COMPRESSOR ${Number.isFinite(compressorHealth) ? `${Math.round(compressorHealth)}%` : 'Unknown'}
-
- ${rheemProblem ? html`
-
- STATUS: ${pretty(diagnosticStatus)} · ACTIVE ALERTS: ${Number.isFinite(alertCount) ? alertCount : 'Unknown'} · Check Repairs or the Rheem message for the vendor error text.
-
- ` : ''}
-
- `;
- }
-
- return html`
- event.stopPropagation()}>
-
-
${body}
-
- `;
- };
-
- const secondsPerSlide = 10;
- const duration = items.length * secondsPerSlide;
- const phase = Math.floor(now.getTime() / 1000) % duration;
- const slot = 100 / items.length;
- const fade = Math.min(4, slot * 0.18);
- const handleAmbientTap = (event) => {
- const visibleSlides = [...event.currentTarget.querySelectorAll('.kitchen-ambient-slide')]
- .map((slide, index) => ({
- index,
- opacity: Number.parseFloat(getComputedStyle(slide).opacity) || 0
- }))
- .sort((left, right) => right.opacity - left.opacity);
- const activeItem = items[visibleSlides[0]?.index ?? 0] || items[0];
- if (!activeItem?.action) return;
- event.stopPropagation();
- callScript(activeItem.action.service, activeItem.action.data);
- };
- return html`
-
-
- ${items.map((item, index) => html`
-
-
-
-
${item.kicker}
-
${item.title}
-
${item.detail}
-
-
- `)}
-
- ${detailActive ? renderDetail() : ''}
- `;
- ]]]
- styles:
- card:
- - height: 235px
- - padding: 0
- - overflow: hidden
- - border-radius: 18px
- - background: linear-gradient(120deg, rgba(33, 150, 243, 0.08), rgba(103, 58, 183, 0.08))
- grid:
- - grid-template-areas: '"ambient"'
- - grid-template-columns: 1fr
- - grid-template-rows: 1fr
- custom_fields:
- ambient:
- - width: 100%
- - height: 100%
- - pointer-events: auto
+ template: kiosk_ambient_alerts
view_layout:
grid-area: ambient
- # Core map cards are required because only HA's history subscription can render Recorder-backed trails.
- - type: conditional
- conditions:
- - &kitchen_family_detail_timer_active
- entity: timer.kitchen_kiosk_detail_display
- state: active
- - entity: input_text.kitchen_kiosk_detail_view
- state: "family|person.carlo"
- card:
- type: map
- entities:
- - person.carlo
- hours_to_show: 24
- theme_mode: dark
- card_mod:
- style: &kitchen_family_history_map_style |
- ha-card {
- position: fixed !important;
- inset: 0 !important;
- z-index: 9999 !important;
- width: 100vw !important;
- height: 100vh !important;
- margin: 0 !important;
- border: 0 !important;
- border-radius: 0 !important;
- background: #03070b !important;
- }
- #root {
- height: 100vh !important;
- padding-bottom: 0 !important;
- }
- #buttons {
- top: 145px !important;
- }
- view_layout:
- grid-area: 1 / 1 / 4 / 3
-
- - type: conditional
- conditions:
- - *kitchen_family_detail_timer_active
- - entity: input_text.kitchen_kiosk_detail_view
- state: "family|person.stacey"
- card:
- type: map
- entities:
- - person.stacey
- hours_to_show: 24
- theme_mode: dark
- card_mod:
- style: *kitchen_family_history_map_style
- view_layout:
- grid-area: 1 / 1 / 4 / 3
-
- - type: conditional
- conditions:
- - *kitchen_family_detail_timer_active
- - entity: input_text.kitchen_kiosk_detail_view
- state: "family|person.justin"
- card:
- type: map
- entities:
- - person.justin
- hours_to_show: 24
- theme_mode: dark
- card_mod:
- style: *kitchen_family_history_map_style
- view_layout:
- grid-area: 1 / 1 / 4 / 3
-
- - type: conditional
- conditions:
- - *kitchen_family_detail_timer_active
- - entity: input_text.kitchen_kiosk_detail_view
- state: "family|person.paige"
- card:
- type: map
- entities:
- - person.paige
- hours_to_show: 24
- theme_mode: dark
- card_mod:
- style: *kitchen_family_history_map_style
- view_layout:
- grid-area: 1 / 1 / 4 / 3
-
- # NOAA is the single radar path; native Kiosk Satellite camera overlays retain priority.
- - type: conditional
- conditions:
- - entity: binary_sensor.kiosk_rain_radar_active
- state: "on"
- card:
- type: custom:weather-radar-card
- data_source: NOAA
- map_style: Dark
- zoom_level: 7
- past_minutes: 20
- frame_stride_minutes: 5
- frame_delay: 650
- restart_delay: 1400
- smooth_animation: true
- smooth_overlap: 0
- motion_compensation: false
- static_map: true
- double_tap_action: none
- show_zoom: false
- show_playback: false
- show_recenter: false
- show_scale: true
- show_range: false
- show_color_bar: true
- show_progress_bar: true
- show_loading_spinner: true
- show_alerts: false
- show_lightning: false
- height: 560px
- width: 100%
- view_layout:
- grid-area: 1 / 1 / 4 / 3
-
- # A core conditional picture card is required for the Driveway source,
- # which is not configured as a native Kiosk Satellite camera shortcut.
- - type: conditional
- conditions:
- - entity: sensor.kiosk_active_camera_view
- state: driveway
- card:
- type: picture-glance
- title: Driveway Alert
- camera_image: camera.driveway
- camera_view: live
- aspect_ratio: "16:10"
- entities: []
+ - type: vertical-stack
+ cards: !include /config/dashboards/kiosk_satellite_staging/partials/event_overlays.yaml
view_layout:
grid-area: 1 / 1 / 4 / 3
diff --git a/config/dashboards/kiosk_satellite_staging/templates.yaml b/config/dashboards/kiosk_satellite_staging/templates.yaml
new file mode 100644
index 00000000..8ef59abd
--- /dev/null
+++ b/config/dashboards/kiosk_satellite_staging/templates.yaml
@@ -0,0 +1,1232 @@
+######################################################################
+# @CCOSTAN - Follow Me on X
+# For more info visit https://www.vcloudinfo.com/click-here
+# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
+# -------------------------------------------------------------------
+# Kiosk Satellite Templates - Shared alerts and compact Echo Show 5
+# One household alert renderer with opt-in small-screen presentation.
+# -------------------------------------------------------------------
+# Notes: Kitchen keeps its existing dimensions and interactive details.
+# Notes: Echo Show 5 uses compact interactive alerts and minute-updated time.
+# Notes: Shared phone notifications join the ambient rotation with sent times and ten-minute expiry.
+# Notes: Direct touch release handles kiosk taps before button-card suppresses synthetic clicks.
+######################################################################
+
+kiosk_ambient_alerts:
+ variables:
+ compact: false
+ read_only: false
+ show_icon: false
+ show_name: false
+ show_state: false
+ tap_action:
+ action: none
+ hold_action:
+ action: none
+ double_tap_action:
+ action: none
+ triggers_update:
+ - binary_sensor.garage_phyn_battery_alert
+ - binary_sensor.garage_phyn_freeze_warning_alert
+ - binary_sensor.garage_phyn_high_pressure_alert
+ - binary_sensor.garage_phyn_leak_alert
+ - binary_sensor.garage_phyn_offline_leak_shutoff_alert
+ - binary_sensor.garage_phyn_pinhole_leak_alert
+ - binary_sensor.garage_phyn_recurring_flow_alert
+ - binary_sensor.garage_phyn_temperature_alert
+ - valve.phyn_shutoff_valve
+ - sensor.phyn_current_water_pressure
+ - sensor.phyn_average_water_flow_rate
+ - sensor.nws_alerts_alerts
+ - group.garage_doors
+ - group.entry_points
+ - group.locks
+ - binary_sensor.front_door_packages_present
+ - sensor.rheem_wh_available_hot_water
+ - sensor.rheem_wh_alert_count
+ - sensor.rheem_wh_diagnostic_status
+ - binary_sensor.rheem_wh_problem
+ - binary_sensor.rheem_wh_active_alert
+ - binary_sensor.rheem_wh_telemetry_stale
+ - binary_sensor.rheem_wh_running
+ - sensor.rheem_wh_tank_health
+ - sensor.rheem_wh_compressor_health
+ - water_heater.rheem_wh
+ - light.sink
+ - input_boolean.garbage_cans_out
+ - input_datetime.water_delivery_date
+ - sensor.water_softener_salt_days_since_last_add
+ - sensor.water_softener_salt_average_days_between_refills
+ - sensor.spaceship_battery_level
+ - switch.spaceship_charge
+ - input_datetime.tesla_model_y_last_garage_close
+ - input_datetime.tesla_model_y_plug_in_snooze_until
+ - sensor.next_booked_cruise_countdown
+ - sensor.bearclaw_status_telemetry
+ - sensor.kiosk_recent_personal_records
+ - sensor.kiosk_recent_phone_notifications
+ - person.carlo
+ - person.stacey
+ - person.justin
+ - person.paige
+ - sensor.carlo_location_display
+ - sensor.stacey_location_display
+ - sensor.justin_location_display
+ - sensor.paige_location_display
+ - binary_sensor.kiosk_rain_radar_weather_event
+ - binary_sensor.kiosk_rain_radar_active
+ - timer.kitchen_kiosk_radar_display
+ - input_text.kitchen_kiosk_detail_view
+ - timer.kitchen_kiosk_detail_display
+ - sensor.kiosk_active_camera_view
+ - camera.frontdoorbell
+ custom_fields:
+ ambient: >
+ [[[
+ const state = (entityId) => states[entityId]?.state;
+ const valid = (value) => !['unknown', 'unavailable', 'none', '', undefined].includes(value);
+ const pretty = (value) => String(value || 'Unknown').replaceAll('_', ' ');
+ const dateKey = (date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ };
+ const items = [];
+ const now = new Date();
+ const detailValue = valid(state('input_text.kitchen_kiosk_detail_view'))
+ ? state('input_text.kitchen_kiosk_detail_view')
+ : 'none';
+ const [detailType, detailSubject = ''] = detailValue.split('|');
+ const detailActive = !variables.read_only && state('timer.kitchen_kiosk_detail_display') === 'active'
+ && detailType !== 'none' && state('sensor.kiosk_active_camera_view') === 'none'
+ && state('binary_sensor.kiosk_rain_radar_active') !== 'on';
+ const callScript = (service, data = {}) =>
+ hass.callService('script', service, data);
+ const closeDetail = (event) => {
+ if (event?.type === 'touchend') event.preventDefault();
+ event?.stopPropagation();
+ hass.callService('timer', 'cancel', {}, {
+ entity_id: 'timer.kitchen_kiosk_detail_display'
+ });
+ };
+
+ const radarWeatherEvent = states['binary_sensor.kiosk_rain_radar_weather_event'];
+ const radarDisplayTimer = states['timer.kitchen_kiosk_radar_display'];
+ const radarReplayReady =
+ radarWeatherEvent?.state === 'on' &&
+ state('binary_sensor.kiosk_rain_radar_active') !== 'on' &&
+ radarDisplayTimer?.state === 'idle' &&
+ new Date(radarDisplayTimer.last_changed).getTime() >
+ new Date(radarWeatherEvent.last_changed).getTime();
+ if (radarReplayReady) {
+ items.push({
+ kicker: 'WEATHER RADAR',
+ title: 'Storms Approaching',
+ detail: 'Tap for radar',
+ icon: 'mdi:radar',
+ accent: '#0288d1',
+ action: {
+ service: 'kitchen_kiosk_show_event',
+ data: {event_type: 'radar'}
+ }
+ });
+ }
+
+ const phynAlerts = [
+ 'binary_sensor.garage_phyn_battery_alert',
+ 'binary_sensor.garage_phyn_freeze_warning_alert',
+ 'binary_sensor.garage_phyn_high_pressure_alert',
+ 'binary_sensor.garage_phyn_leak_alert',
+ 'binary_sensor.garage_phyn_offline_leak_shutoff_alert',
+ 'binary_sensor.garage_phyn_pinhole_leak_alert',
+ 'binary_sensor.garage_phyn_recurring_flow_alert',
+ 'binary_sensor.garage_phyn_temperature_alert'
+ ].filter((entityId) => state(entityId) === 'on');
+ const phynValveState = state('valve.phyn_shutoff_valve');
+ if (phynAlerts.length > 0 || (valid(phynValveState) && phynValveState !== 'open')) {
+ const alertNames = phynAlerts.map((entityId) =>
+ states[entityId]?.attributes?.friendly_name || pretty(entityId.split('.')[1])
+ );
+ items.push({
+ kicker: 'WATER SYSTEM',
+ title: phynValveState === 'closed' ? 'Main water is off' : 'Phyn needs attention',
+ detail: `${alertNames.join(' · ') || `Valve is ${pretty(phynValveState)}`} · Tap for water details`,
+ icon: 'mdi:water-alert-outline',
+ accent: '#c62828',
+ action: {
+ service: 'kitchen_kiosk_show_detail',
+ data: {detail_type: 'phyn'}
+ }
+ });
+ }
+
+ const weatherAlertEntity = states['sensor.nws_alerts_alerts'];
+ const weatherAlertCount = Number.parseInt(weatherAlertEntity?.state, 10);
+ const weatherAlerts = Array.isArray(weatherAlertEntity?.attributes?.Alerts)
+ ? weatherAlertEntity.attributes.Alerts
+ : [];
+ const cleanAlertText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
+ const alertSection = (description, label) => {
+ const pattern = new RegExp(
+ `\\*\\s*${label}\\.{3}\\s*([\\s\\S]*?)(?=\\n\\s*\\n\\s*\\*\\s*[A-Z ]+\\.{3}|$)`,
+ 'i'
+ );
+ return cleanAlertText(String(description || '').match(pattern)?.[1]);
+ };
+ const limitAlertText = (value, maxLength = 240) => {
+ const text = cleanAlertText(value);
+ if (text.length <= maxLength) return text;
+ return `${text.slice(0, maxLength - 3).replace(/\s+\S*$/, '')}...`;
+ };
+ const severityOrder = { Extreme: 0, Severe: 1, Moderate: 2, Minor: 3, Unknown: 4 };
+ const severityColors = {
+ Extreme: '#b71c1c',
+ Severe: '#c62828',
+ Moderate: '#d84315',
+ Minor: '#f9a825',
+ Unknown: '#546e7a'
+ };
+ const uniqueWeatherAlerts = weatherAlerts
+ .filter((alert, index, alerts) => {
+ const alertKey = alert.ID || alert.URL || `${alert.Event}|${alert.Expires}`;
+ return alerts.findIndex((candidate) =>
+ (candidate.ID || candidate.URL || `${candidate.Event}|${candidate.Expires}`) === alertKey
+ ) === index;
+ })
+ .sort((left, right) =>
+ (severityOrder[left.Severity] ?? severityOrder.Unknown)
+ - (severityOrder[right.Severity] ?? severityOrder.Unknown)
+ );
+
+ if (uniqueWeatherAlerts.length > 0) {
+ uniqueWeatherAlerts.forEach((alert, index) => {
+ const severity = severityColors[alert.Severity] ? alert.Severity : 'Unknown';
+ const detailParts = [
+ alertSection(alert.Description, 'WHAT'),
+ alertSection(alert.Description, 'WHERE'),
+ alertSection(alert.Description, 'WHEN')
+ ].filter(Boolean);
+ const detail = detailParts.length > 0
+ ? detailParts.join(' · ')
+ : alert.Headline || alert.Description || 'Active National Weather Service alert';
+ const position = uniqueWeatherAlerts.length > 1
+ ? ` ${index + 1}/${uniqueWeatherAlerts.length}`
+ : '';
+ items.push({
+ kicker: `WEATHER ALERT${position} · ${severity.toUpperCase()}`,
+ title: alert.Event || `${weatherAlertCount} active alerts`,
+ detail: limitAlertText(detail),
+ icon: 'mdi:weather-lightning-rainy',
+ accent: severityColors[severity],
+ action: {
+ service: 'kitchen_kiosk_show_detail',
+ data: {
+ detail_type: 'weather',
+ detail_subject: String(alert.ID || index)
+ }
+ }
+ });
+ });
+ } else if (Number.isFinite(weatherAlertCount) && weatherAlertCount > 0) {
+ items.push({
+ kicker: 'WEATHER ALERT',
+ title: `${weatherAlertCount} active ${weatherAlertCount === 1 ? 'alert' : 'alerts'}`,
+ detail: 'Alert details are temporarily unavailable',
+ icon: 'mdi:weather-lightning-rainy',
+ accent: severityColors.Unknown,
+ action: {
+ service: 'kitchen_kiosk_show_detail',
+ data: {detail_type: 'weather', detail_subject: '0'}
+ }
+ });
+ }
+
+ if (valid(state('group.garage_doors')) && !['closed', 'off'].includes(state('group.garage_doors'))) {
+ const openGarages = ['cover.large_garage_door', 'cover.small_garage_door']
+ .filter((entityId) => valid(state(entityId)) && state(entityId) !== 'closed')
+ .map((entityId) => states[entityId]?.attributes?.friendly_name || pretty(entityId.split('.')[1]));
+ items.push({
+ kicker: 'GARAGE',
+ title: 'Garage door open',
+ detail: openGarages.join(' · ') || 'Check both garage doors',
+ icon: 'mdi:garage-open-variant',
+ accent: '#ef6c00'
+ });
+ }
+
+ if (state('group.entry_points') === 'on') {
+ const openEntries = (states['group.entry_points']?.attributes?.entity_id || [])
+ .filter((entityId) => state(entityId) === 'on')
+ .map((entityId) => states[entityId]?.attributes?.friendly_name || pretty(entityId.split('.')[1]));
+ const extraCount = Math.max(0, openEntries.length - 3);
+ const entryDetail = openEntries.slice(0, 3).join(' · ') + (extraCount ? ` · +${extraCount} more` : '');
+ items.push({
+ kicker: 'ENTRY POINT',
+ title: openEntries.length === 1 ? 'Door or window open' : `${openEntries.length} openings detected`,
+ detail: entryDetail || 'Check the exterior doors and windows',
+ icon: 'mdi:door-open',
+ accent: '#ef6c00',
+ action: {
+ service: 'kitchen_kiosk_show_detail',
+ data: {detail_type: 'entry_points'}
+ }
+ });
+ }
+
+ if (valid(state('group.locks')) && state('group.locks') !== 'locked') {
+ items.push({
+ kicker: 'SECURITY',
+ title: 'Check the front door lock',
+ detail: `Lock group is ${pretty(state('group.locks'))}`,
+ icon: 'mdi:lock-alert-outline',
+ accent: '#c62828'
+ });
+ }
+
+ if (state('binary_sensor.front_door_packages_present') === 'on') {
+ items.push({
+ kicker: 'FRONT DOOR',
+ title: 'Package waiting',
+ detail: 'Tap for the front door view',
+ icon: 'mdi:package-variant-closed',
+ accent: '#8e5a2b',
+ action: {
+ service: 'kitchen_kiosk_show_detail',
+ data: {detail_type: 'package'}
+ }
+ });
+ }
+
+ const rheemAlertCount = Number.parseInt(state('sensor.rheem_wh_alert_count'), 10);
+ const rheemStatus = state('sensor.rheem_wh_diagnostic_status');
+ const rheemProblem = state('binary_sensor.rheem_wh_problem') === 'on';
+ if (rheemProblem) {
+ const activeAlert = state('binary_sensor.rheem_wh_active_alert') === 'on';
+ const telemetryStale = state('binary_sensor.rheem_wh_telemetry_stale') === 'on';
+ items.push({
+ kicker: telemetryStale && !activeAlert ? 'WATER HEATER TELEMETRY' : 'WATER HEATER ALERT',
+ title: activeAlert && Number.isFinite(rheemAlertCount)
+ ? `${rheemAlertCount} active ${rheemAlertCount === 1 ? 'alert' : 'alerts'}`
+ : `Rheem status: ${pretty(rheemStatus)}`,
+ detail: telemetryStale
+ ? 'EcoNet data has been unavailable for 10 minutes · Tap for diagnostics'
+ : 'Home Assistant Repairs has the diagnostic snapshot · Tap for details',
+ icon: 'mdi:water-boiler-alert',
+ accent: telemetryStale && !activeAlert ? '#ef6c00' : '#c62828',
+ action: {
+ service: 'kitchen_kiosk_show_detail',
+ data: {detail_type: 'hot_water'}
+ }
+ });
+ }
+
+ const hotWater = Number.parseFloat(state('sensor.rheem_wh_available_hot_water'));
+ if (!rheemProblem && Number.isFinite(hotWater) && hotWater < 30) {
+ items.push({
+ kicker: 'HOT WATER',
+ title: 'Hot water is running low',
+ detail: `${Math.round(hotWater)}% available · Tap for recovery details`,
+ icon: 'mdi:water-thermometer-outline',
+ accent: '#0277bd',
+ action: {
+ service: 'kitchen_kiosk_show_detail',
+ data: {detail_type: 'hot_water'}
+ }
+ });
+ }
+
+ const weekday = now.toLocaleDateString('en-US', {weekday: 'short'});
+ if (['Wed', 'Sun'].includes(weekday) && state('input_boolean.garbage_cans_out') !== 'on') {
+ items.push({
+ kicker: 'TODAY',
+ title: weekday === 'Wed' ? 'Garbage + recycling day' : 'Garbage day',
+ detail: 'Put the cans at the curb',
+ icon: 'mdi:trash-can-outline',
+ accent: '#2e7d32'
+ });
+ }
+
+ const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
+ if (state('input_datetime.water_delivery_date') === dateKey(tomorrow)) {
+ items.push({
+ kicker: 'WATER DELIVERY TOMORROW',
+ title: 'Put the bottles out',
+ detail: 'Stage them before leaving the house',
+ icon: 'mdi:bottle-soda-classic-outline',
+ accent: '#0277bd'
+ });
+ }
+
+ const battery = Number.parseFloat(state('sensor.spaceship_battery_level'));
+ const lastClose = new Date(String(state('input_datetime.tesla_model_y_last_garage_close')).replace(' ', 'T'));
+ const snoozeUntil = new Date(String(state('input_datetime.tesla_model_y_plug_in_snooze_until')).replace(' ', 'T'));
+ const recentArrival = Number.isFinite(lastClose.getTime()) && (now.getTime() - lastClose.getTime()) < 43200000;
+ const snoozeExpired = !Number.isFinite(snoozeUntil.getTime()) || snoozeUntil <= now;
+ if (
+ Number.isFinite(battery) && battery < 50 && recentArrival && snoozeExpired &&
+ state('switch.spaceship_charge') === 'off' &&
+ state('person.carlo') === 'home' && state('person.stacey') === 'home'
+ ) {
+ items.push({
+ kicker: 'MODEL Y',
+ title: 'Plug in the Tesla',
+ detail: `${Math.round(battery)}% battery after the latest garage arrival`,
+ icon: 'mdi:ev-plug-tesla',
+ accent: '#c62828'
+ });
+ }
+
+ if (state('light.sink') === 'unavailable') {
+ items.push({
+ kicker: 'CHECK WALL SWITCH',
+ title: 'Kitchen sink light',
+ detail: 'Please turn its physical switch back on.',
+ icon: 'mdi:lightbulb-alert-outline',
+ accent: '#ef6c00'
+ });
+ }
+
+ const saltDays = Number.parseFloat(state('sensor.water_softener_salt_days_since_last_add'));
+ const saltAverage = Number.parseFloat(state('sensor.water_softener_salt_average_days_between_refills'));
+ if (
+ Number.isFinite(saltDays) && Number.isFinite(saltAverage) && saltAverage > 0 &&
+ saltDays >= saltAverage * 0.9
+ ) {
+ items.push({
+ kicker: 'WATER SOFTENER',
+ title: 'Salt refill coming due',
+ detail: `${Math.round(saltDays)} days since the last refill`,
+ icon: 'mdi:shaker-outline',
+ accent: '#00838f'
+ });
+ }
+
+ const bowlingHighlights = states['sensor.bearclaw_status_telemetry']?.attributes?.bowlingHighlights;
+ if (bowlingHighlights?.active && Array.isArray(bowlingHighlights.items)) {
+ bowlingHighlights.items.forEach((highlight) => {
+ const expiresAt = new Date(highlight?.expiresAt || '');
+ const games = (Array.isArray(highlight?.games) ? highlight.games : [])
+ .map((score) => Number.parseInt(score, 10))
+ .filter(Number.isFinite);
+ const highGame = Number.parseInt(highlight?.highGame, 10);
+ const seriesTotal = Number.parseInt(highlight?.seriesTotal, 10);
+ if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= now || !games.length || !Number.isFinite(highGame)) return;
+ const records = new Set(Array.isArray(highlight?.personalBestIds) ? highlight.personalBestIds : []);
+ const highGameRecord = records.has('bowling_high_game');
+ const highSeriesRecord = records.has('bowling_high_series') && Number.isFinite(seriesTotal);
+ const title = highGameRecord
+ ? `${highlight.displayName}'s new high game: ${highGame}`
+ : (highSeriesRecord
+ ? `${highlight.displayName}'s new high series: ${seriesTotal}`
+ : `${highlight.displayName}'s high game: ${highGame}`);
+ const detail = [
+ games.join(' / '),
+ Number.isFinite(seriesTotal) ? `${seriesTotal} series` : '',
+ Number.isFinite(Number(highlight?.weekNum)) ? `Week ${highlight.weekNum}` : ''
+ ].filter(Boolean).join(' · ');
+ items.push({
+ kicker: highGameRecord || highSeriesRecord ? 'BOWLING PERSONAL BEST' : 'TUESDAY BOWLING',
+ title,
+ detail,
+ icon: 'mdi:bowling',
+ accent: '#7e57c2'
+ });
+ });
+ }
+
+ const phoneFeed = states['sensor.kiosk_recent_phone_notifications'];
+ if (Array.isArray(phoneFeed?.attributes?.items)) {
+ phoneFeed.attributes.items.forEach((notification) => {
+ if (Number(notification.expires_at) * 1000 <= now.getTime()) return;
+ items.push({
+ kicker: `PHONE NOTIFICATION · ${notification.sent_time}`,
+ title: notification.title,
+ detail: notification.message,
+ icon: notification.icon,
+ accent: notification.color
+ });
+ });
+ }
+
+ const recordFeed = states['sensor.kiosk_recent_personal_records'];
+ const personalRecords = recordFeed?.state === 'ready' ? recordFeed.attributes?.items : null;
+ if (Array.isArray(personalRecords)) {
+ personalRecords.forEach((record) => {
+ const occurredAt = new Date(record?.occurredAt || '');
+ if (!Number.isFinite(occurredAt.getTime()) ||
+ occurredAt > now || now - occurredAt > 7 * 24 * 60 * 60 * 1000 ||
+ !record?.person || !record?.label || !record?.displayValue) return;
+ items.push({
+ kicker: 'NEW PERSONAL BEST',
+ title: `${record.person}: ${record.label}`,
+ detail: [record.displayValue, record.activityType, record.dayKey].filter(Boolean).join(' · '),
+ icon: 'mdi:trophy-outline',
+ accent: '#2e7d32'
+ });
+ });
+ }
+
+ const cruiseDays = Number.parseInt(state('sensor.next_booked_cruise_countdown'), 10);
+ const cruiseEntity = states['sensor.next_booked_cruise_countdown'];
+ const cruiseShip = String(cruiseEntity?.attributes?.ship || '').trim();
+ const cruiseDate = String(cruiseEntity?.attributes?.sail_date || '').trim();
+ if (Number.isInteger(cruiseDays) && cruiseDays >= 0 && cruiseShip && cruiseDate) {
+ const sailDate = new Date(`${cruiseDate}T12:00:00`);
+ const sailLabel = Number.isFinite(sailDate.getTime())
+ ? sailDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
+ : cruiseDate;
+ const cruiseNights = Number.parseInt(cruiseEntity?.attributes?.nights, 10);
+ const cruisePort = String(cruiseEntity?.attributes?.port || '').trim();
+ const detail = [
+ `Departs ${sailLabel}`,
+ Number.isInteger(cruiseNights) && cruiseNights > 0
+ ? `${cruiseNights} ${cruiseNights === 1 ? 'night' : 'nights'}`
+ : '',
+ cruisePort
+ ].filter(Boolean).join(' · ');
+ items.push({
+ kicker: 'NEXT BOOKED CRUISE',
+ title: cruiseDays === 0
+ ? `${cruiseShip} sails today`
+ : `${cruiseDays} ${cruiseDays === 1 ? 'day' : 'days'} until ${cruiseShip}`,
+ detail,
+ icon: 'mdi:ferry',
+ accent: '#00838f'
+ });
+ }
+
+ [
+ ['Carlo', 'person.carlo', 'sensor.carlo_location_display'],
+ ['Stacey', 'person.stacey', 'sensor.stacey_location_display'],
+ ['Justin', 'person.justin', 'sensor.justin_location_display'],
+ ['Paige', 'person.paige', 'sensor.paige_location_display']
+ ].forEach(([name, personId, locationId]) => {
+ const personState = state(personId);
+ if (valid(personState) && personState !== 'home') {
+ const locationEntity = states[locationId];
+ const rawLocation = pretty(valid(state(locationId)) ? state(locationId) : personState);
+ const withoutName = rawLocation.replace(new RegExp(`^${name}\\s+`, 'i'), '');
+ const sinceMatch = withoutName.match(/\s*\(since\s+([^)]+)\)\s*$/i);
+ const location = withoutName.replace(/\s*\(since\s+[^)]+\)\s*$/i, '').trim();
+ const changedAt = new Date(locationEntity?.last_changed || states[personId]?.last_changed);
+ const since = sinceMatch?.[1] || (
+ Number.isFinite(changedAt.getTime())
+ ? changedAt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
+ : 'recently'
+ );
+ items.push({
+ kicker: 'FAMILY LOCATION',
+ title: `${name} - ${location || pretty(personState)}`,
+ detail: `Since ${since}`,
+ icon: 'mdi:map-marker-account-outline',
+ accent: '#6a1b9a',
+ action: {
+ service: 'kitchen_kiosk_show_detail',
+ data: {detail_type: 'family', detail_subject: personId}
+ }
+ });
+ }
+ });
+
+ if (items.length === 0) {
+ items.push({
+ kicker: 'HOUSEHOLD',
+ title: 'Everything is handled',
+ detail: 'Everyone is home and there are no active reminders',
+ icon: 'mdi:check-circle-outline',
+ accent: '#2e7d32'
+ });
+ }
+
+ const formatSince = (entity) => {
+ const changed = new Date(entity?.last_changed);
+ if (!Number.isFinite(changed.getTime())) return 'recently';
+ const minutes = Math.max(0, Math.floor((Date.now() - changed.getTime()) / 60000));
+ if (minutes < 1) return 'just now';
+ if (minutes < 60) return `${minutes} min ago`;
+ const hours = Math.floor(minutes / 60);
+ return `${hours} hr${hours === 1 ? '' : 's'} ago`;
+ };
+ const renderDetail = () => {
+ let kicker = 'HOUSEHOLD DETAIL';
+ let title = 'Current status';
+ let icon = 'mdi:information-outline';
+ let body = html`This detail is no longer active.
`;
+
+ if (detailType === 'weather') {
+ const alert = uniqueWeatherAlerts.find((candidate, index) =>
+ String(candidate.ID || index) === detailSubject
+ ) || uniqueWeatherAlerts[0];
+ kicker = `NWS WEATHER ALERT · ${String(alert?.Severity || 'Unknown').toUpperCase()}`;
+ title = alert?.Event || 'Weather alert';
+ icon = 'mdi:weather-lightning-rainy';
+ if (alert) {
+ const sections = [
+ ['WHAT', alertSection(alert.Description, 'WHAT')],
+ ['WHERE', alertSection(alert.Description, 'WHERE')],
+ ['WHEN', alertSection(alert.Description, 'WHEN')],
+ ['IMPACTS', alertSection(alert.Description, 'IMPACTS')]
+ ].filter(([, value]) => value);
+ const instructions = limitAlertText(alert.Instruction, 220);
+ body = html`
+ ${cleanAlertText(alert.Headline)}
+
+ ${sections.map(([label, value]) => html`
+
+ `)}
+
+ ${instructions ? html`
+
+ STAY SAFE · ${instructions}
+
+ ` : ''}
+ `;
+ }
+ } else if (detailType === 'package') {
+ const camera = states['camera.frontdoorbell'];
+ const packageEntity = states['binary_sensor.front_door_packages_present'];
+ const picture = String(camera?.attributes?.entity_picture || '');
+ kicker = 'FRONT DOOR';
+ title = 'Package waiting';
+ icon = 'mdi:package-variant-closed';
+ body = html`
+
+ ${picture ? html`
` : ''}
+
+ Last checked ${packageEntity?.attributes?.last_run || 'recently'}
+
+
+ `;
+ } else if (detailType === 'family') {
+ const person = states[detailSubject];
+ const locationEntityId = {
+ 'person.carlo': 'sensor.carlo_location_display',
+ 'person.stacey': 'sensor.stacey_location_display',
+ 'person.justin': 'sensor.justin_location_display',
+ 'person.paige': 'sensor.paige_location_display'
+ }[detailSubject];
+ const personName = person?.attributes?.friendly_name || pretty(detailSubject.split('.')[1]);
+ const rawLocation = pretty(state(locationEntityId));
+ const withoutName = rawLocation.replace(new RegExp(`^${personName}\s+`, 'i'), '');
+ const location = withoutName.replace(/\s*\(since\s+[^)]+\)\s*$/i, '').trim();
+ kicker = 'FAMILY LOCATION';
+ title = `${personName} · ${location || pretty(person?.state)}`;
+ icon = 'mdi:map-marker-account-outline';
+ body = html`Updated ${formatSince(person)} · 24-hour trail
`;
+ } else if (detailType === 'entry_points') {
+ const openEntries = (states['group.entry_points']?.attributes?.entity_id || [])
+ .map((entityId) => states[entityId])
+ .filter((entry) => entry?.state === 'on');
+ kicker = 'ENTRY POINTS';
+ title = `${openEntries.length} ${openEntries.length === 1 ? 'opening' : 'openings'} detected`;
+ icon = 'mdi:door-open';
+ body = html`
+
+ ${openEntries.map((entry) => html`
+
+
+
+
${entry.attributes?.friendly_name || 'Open entry'}
+
Open ${formatSince(entry)}
+
+
+ `)}
+
+ `;
+ } else if (detailType === 'phyn') {
+ const valveState = state('valve.phyn_shutoff_valve');
+ const flow = Number.parseFloat(state('sensor.phyn_average_water_flow_rate'));
+ const pressure = Number.parseFloat(state('sensor.phyn_current_water_pressure'));
+ const alerts = phynAlerts.map((entityId) =>
+ states[entityId]?.attributes?.friendly_name || pretty(entityId.split('.')[1])
+ );
+ kicker = 'PHYN WATER SYSTEM';
+ title = valveState === 'closed' ? 'Main water is off' : 'Water system needs attention';
+ icon = 'mdi:water-alert-outline';
+ body = html`
+
+
+ MAIN VALVE ${pretty(valveState)}
+
+
+ FLOW ${Number.isFinite(flow) ? `${flow.toFixed(1)} gal/min` : 'Unknown'}
+
+
+ PRESSURE ${Number.isFinite(pressure) ? `${pressure.toFixed(0)} psi` : 'Unknown'}
+
+
+
+ ${alerts.length ? alerts.join(' · ') : 'No active Phyn alert text is available.'}
+
+ ${valveState === 'closed' ? html`
+ {
+ event.stopPropagation();
+ if (window.confirm('Turn the Phyn main water supply ON? Confirm the leak is safe first.')) {
+ callScript('kitchen_kiosk_restore_phyn_water');
+ }
+ }}
+ >TURN WATER ON
+ ` : html`Main water is ${pretty(valveState)}
`}
+ `;
+ } else if (detailType === 'hot_water') {
+ const available = Number.parseFloat(state('sensor.rheem_wh_available_hot_water'));
+ const alertCount = Number.parseInt(state('sensor.rheem_wh_alert_count'), 10);
+ const diagnosticStatus = state('sensor.rheem_wh_diagnostic_status');
+ const rheemProblem = state('binary_sensor.rheem_wh_problem') === 'on';
+ const heater = states['water_heater.rheem_wh'];
+ const running = state('binary_sensor.rheem_wh_running') === 'on';
+ const target = Number.parseFloat(heater?.attributes?.temperature);
+ const mode = pretty(heater?.attributes?.operation_mode || heater?.state);
+ const tankHealth = Number.parseFloat(state('sensor.rheem_wh_tank_health'));
+ const compressorHealth = Number.parseFloat(state('sensor.rheem_wh_compressor_health'));
+ kicker = rheemProblem ? 'WATER HEATER ALERT' : 'HOT WATER RECOVERY';
+ title = rheemProblem && Number.isFinite(alertCount) && alertCount > 0
+ ? `${alertCount} active ${alertCount === 1 ? 'alert' : 'alerts'}`
+ : (running ? 'Heating now' : 'Standing by');
+ icon = rheemProblem ? 'mdi:water-boiler-alert' : 'mdi:water-boiler';
+ body = html`
+
+
${Number.isFinite(available) ? Math.round(available) : '--'}%
+
HOT WATER AVAILABLE
+
+
MODE ${mode}
+
TARGET ${Number.isFinite(target) ? `${Math.round(target)}°F` : 'Unknown'}
+
TANK HEALTH ${Number.isFinite(tankHealth) ? `${Math.round(tankHealth)}%` : 'Unknown'}
+
COMPRESSOR ${Number.isFinite(compressorHealth) ? `${Math.round(compressorHealth)}%` : 'Unknown'}
+
+ ${rheemProblem ? html`
+
+ STATUS: ${pretty(diagnosticStatus)} · ACTIVE ALERTS: ${Number.isFinite(alertCount) ? alertCount : 'Unknown'} · Check Repairs or the Rheem message for the vendor error text.
+
+ ` : ''}
+
+ `;
+ }
+
+ return html`
+ event.stopPropagation()}>
+
+
${body}
+
+ `;
+ };
+
+ const secondsPerSlide = 10;
+ const duration = items.length * secondsPerSlide;
+ const phase = Math.floor(now.getTime() / 1000) % duration;
+ const slot = 100 / items.length;
+ const fade = Math.min(4, slot * 0.18);
+ const handleAmbientTap = (event) => {
+ if (variables.read_only) return;
+ if (event.type === 'touchend') event.preventDefault();
+ const visibleSlides = [...event.currentTarget.querySelectorAll('.kitchen-ambient-slide')]
+ .map((slide, index) => ({
+ index,
+ opacity: Number.parseFloat(getComputedStyle(slide).opacity) || 0
+ }))
+ .sort((left, right) => right.opacity - left.opacity);
+ const activeItem = items[visibleSlides[0]?.index ?? 0] || items[0];
+ if (!activeItem?.action) return;
+ event.stopPropagation();
+ callScript(activeItem.action.service, activeItem.action.data);
+ };
+ return html`
+
+
+ ${items.map((item, index) => html`
+
+
+
+
${item.kicker}
+
${item.title}
+
${item.detail}
+
+
+ `)}
+
+ ${detailActive ? renderDetail() : ''}
+ `;
+ ]]]
+ styles:
+ card:
+ - height: '[[[ return variables.compact ? "126px" : "235px"; ]]]'
+ - padding: 0
+ - overflow: hidden
+ - border-radius: 18px
+ - background: >
+ [[[ return states['timer.kitchen_kiosk_detail_display']?.state === 'active' ? 'transparent'
+ : 'linear-gradient(120deg, rgba(33, 150, 243, 0.08), rgba(103, 58, 183, 0.08))'; ]]]
+ grid:
+ - grid-template-areas: '"ambient"'
+ - grid-template-columns: 1fr
+ - grid-template-rows: 1fr
+ custom_fields:
+ ambient:
+ - width: 100%
+ - height: 100%
+ - pointer-events: '[[[ return variables.read_only ? "none" : "auto"; ]]]'
+
+kiosk_echo5_surface:
+ show_icon: false
+ show_name: false
+ show_state: false
+ triggers_update:
+ - timer.kitchen_kiosk_detail_display
+ - sensor.kiosk_active_camera_view
+ - binary_sensor.kiosk_rain_radar_active
+ tap_action:
+ action: none
+ hold_action:
+ action: none
+ styles:
+ card:
+ - height: calc(100vh - 8px)
+ - box-sizing: border-box
+ - padding: 4px
+ - border: 0
+ - border-radius: 0
+ - background: var(--lovelace-background)
+ - box-shadow: none
+ grid:
+ - grid-template-areas: '"clock ambient" "cameras cameras"'
+ - grid-template-columns: minmax(0, 0.33fr) minmax(0, 0.67fr)
+ - grid-template-rows: 126px minmax(0, 1fr)
+ - gap: 6px
+ custom_fields:
+ clock:
+ - min-width: 0
+ - align-self: stretch
+ ambient:
+ - min-width: 0
+ - align-self: stretch
+ # Raise the interactive detail above the map's sibling stacking context.
+ - position: relative
+ - z-index: >
+ [[[ return states['timer.kitchen_kiosk_detail_display']?.state === 'active'
+ && states['sensor.kiosk_active_camera_view']?.state === 'none'
+ && states['binary_sensor.kiosk_rain_radar_active']?.state !== 'on' ? 10000 : 'auto'; ]]]
+ cameras:
+ - min-width: 0
+ - align-self: start
+ events:
+ - position: absolute
+ - inset: 0
+ - pointer-events: none
+ - z-index: 9999
+
+kiosk_echo5_clock:
+ # The requested icon is current weather status beside the time, with no forecast.
+ show_icon: true
+ show_name: false
+ show_state: false
+ icon: >
+ [[[
+ const icons = {
+ 'clear-night': 'mdi:weather-night', sunny: 'mdi:weather-sunny',
+ partlycloudy: 'mdi:weather-partly-cloudy', cloudy: 'mdi:weather-cloudy',
+ fog: 'mdi:weather-fog', rainy: 'mdi:weather-rainy', pouring: 'mdi:weather-pouring',
+ lightning: 'mdi:weather-lightning', 'lightning-rainy': 'mdi:weather-lightning-rainy',
+ snowy: 'mdi:weather-snowy', 'snowy-rainy': 'mdi:weather-snowy-rainy',
+ hail: 'mdi:weather-hail', windy: 'mdi:weather-windy',
+ 'windy-variant': 'mdi:weather-windy-variant', exceptional: 'mdi:alert-outline'
+ };
+ return icons[states[variables.weather_entity]?.state] || 'mdi:help-circle-outline';
+ ]]]
+ tap_action:
+ action: none
+ hold_action:
+ action: none
+ custom_fields:
+ time: >
+ [[[
+ const value = entity?.state || '';
+ if (!/^\d{2}:\d{2}$/.test(value)) return '--:--';
+ const [hour, minute] = value.split(':');
+ return `${Number(hour) % 12 || 12}:${minute}`;
+ ]]]
+ date: >
+ [[[
+ const value = states[variables.date_entity]?.state || '';
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return '';
+ return new Date(`${value}T12:00:00`).toLocaleDateString('en-US', {
+ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric'
+ });
+ ]]]
+ styles:
+ card:
+ - height: 126px
+ - padding: 4px 8px
+ - box-sizing: border-box
+ - border: 0
+ - background: transparent
+ - box-shadow: none
+ grid:
+ - grid-template-areas: '"i time" "date date"'
+ - grid-template-columns: 46px minmax(0, 1fr)
+ - grid-template-rows: 1fr auto
+ - column-gap: 8px
+ icon:
+ - width: 42px
+ - color: '#b6d8f3'
+ custom_fields:
+ time:
+ - justify-self: start
+ - align-self: center
+ - font-size: 64px
+ - font-weight: 600
+ - line-height: 1
+ - font-variant-numeric: tabular-nums
+ - color: var(--primary-text-color)
+ date:
+ - justify-self: start
+ - text-align: left
+ - white-space: normal
+ - font-size: 14px
+ - line-height: 1.3
+ - font-weight: 500
+ - color: var(--secondary-text-color)
diff --git a/config/dashboards/kiosk_satellite_staging/views/02_echo_show5.yaml b/config/dashboards/kiosk_satellite_staging/views/02_echo_show5.yaml
new file mode 100644
index 00000000..dcb62c03
--- /dev/null
+++ b/config/dashboards/kiosk_satellite_staging/views/02_echo_show5.yaml
@@ -0,0 +1,62 @@
+######################################################################
+# @CCOSTAN - Follow Me on X
+# For more info visit https://www.vcloudinfo.com/click-here
+# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
+# -------------------------------------------------------------------
+# Echo Show 5 View - Clock, household alerts, and cameras
+# Compact landscape camera wall with a current-weather icon and no forecast.
+# -------------------------------------------------------------------
+# Notes: Kitchen keeps its existing dimensions and interactive details.
+# Notes: Echo Show 5 uses compact alerts with shared full-screen details and minute-updated time.
+######################################################################
+
+title: Echo Show 5
+path: echo-show5
+icon: mdi:tablet-dashboard
+theme: kiosk_satellite_dark
+type: panel
+cards:
+ - type: custom:button-card
+ template: kiosk_echo5_surface
+ custom_fields:
+ clock:
+ card:
+ type: custom:button-card
+ template: kiosk_echo5_clock
+ entity: sensor.time
+ triggers_update:
+ - sensor.date
+ - weather.pirateweather
+ variables:
+ date_entity: sensor.date
+ weather_entity: weather.pirateweather
+ ambient:
+ card:
+ type: custom:button-card
+ template: kiosk_ambient_alerts
+ variables:
+ compact: true
+ read_only: false
+ cameras:
+ card:
+ type: grid
+ columns: 2
+ square: false
+ cards:
+ # Core picture-glance is required for HA camera images; button-card cannot render them.
+ - type: picture-glance
+ title: Front Lawn
+ camera_image: camera.frontlawn
+ camera_view: auto
+ aspect_ratio: "16:10"
+ entities: []
+ - type: picture-glance
+ title: Driveway
+ camera_image: camera.driveway
+ camera_view: auto
+ aspect_ratio: "16:10"
+ entities: []
+ events:
+ card:
+ type: vertical-stack
+ cards: !include /config/dashboards/kiosk_satellite_staging/partials/event_overlays.yaml
diff --git a/config/packages/kiosk_tablet.yaml b/config/packages/kiosk_tablet.yaml
index 9e4c7da7..c8f6b102 100644
--- a/config/packages/kiosk_tablet.yaml
+++ b/config/packages/kiosk_tablet.yaml
@@ -16,21 +16,29 @@
# Notes: Kitchen Show uses Kiosk Satellite's native ESPHome entities; `_2` is the live set while legacy MQTT registry entries coexist.
# Notes: Kiosk Satellite owns HA kiosk-mode hiding, connection recovery, HA camera import, and HLS-first playback; no kiosk-mode custom resource is loaded.
# Notes: Kitchen Show uses the Dim screensaver while empty or `group.bed` is on; true panel-off is disabled because this Crown build naps Wi-Fi and cannot accept a remote wake.
-# Notes: Kitchen Show master volume is 13% while either bed is occupied and 75% once both are empty; bed changes, 08:00, and startup reconcile it.
+# Notes: Kitchen volume follows bed occupancy (13% occupied, 75% empty); Upstairs and Office volume stay manual.
# Notes: Garage and Front Door retain priority; a Driveway alert takes over both kiosks until occupancy clears or ten minutes elapse.
# Notes: At 07:00, Home Assistant stages a pending Kiosk Satellite release, then dispatches Joanna daily until installation is verified.
# Notes: Bedroom wake events only restore brightness/focus; URL reloads require page drift.
# Notes: Fully auto-reload on screen-on/screensaver-stop is disabled to avoid motion wake reload loops.
-# Notes: Startup recovery waits for HA's initial websocket state burst, then reloads the Kitchen page once.
+# Notes: Startup recovery waits for HA's initial websocket state burst, then reloads each Show page once.
# Notes: Brightness recovery only runs when both SleepIQ in-bed sensors are off.
-# Notes: Fully availability/page/app/power plus Kitchen connectivity, current-page/screenshot freshness, and update state feed Systems telemetry.
-# Notes: Kitchen updates are advisory telemetry and do not raise kiosk or Systems fault rollups.
+# Notes: Fully availability/page/app/power plus Kitchen, Upstairs and Office native health feed Systems telemetry.
+# Notes: Upstairs and Office use the compact Echo Show 5 view; shared snapshots and startup recovery cover enrolled Shows.
+# Notes: Shared kiosk_health macros keep connection, page, power, screenshot, and advisory-update checks aligned.
+# Notes: Show updates are advisory telemetry and do not raise kiosk or Systems fault rollups.
+# Notes: Native Shows share screen, volume, camera, snapshot and 07:00 update policies through satellite_devices.
+# Notes: Updates run followers first; Joanna receives an exact enrolled device key and preserves its route.
# Notes: Kiosk attention sensors expose only their stable status attribute so recorder preserves problem transitions without heartbeat churn.
# Notes: Nearby daytime rain or lightning starts a five-minute NOAA radar view; the ambient banner offers daylight replay while the event remains active.
# Notes: Actionable Kitchen banner slides open one shared short-lived detail surface; camera events retain priority.
# Notes: Phyn detail can restore a closed main valve only after an explicit on-screen confirmation.
# Notes: Rheem faults outrank low-hot-water reminders and reuse the hot-water diagnostic detail surface.
# Notes: Verified Health Coach personal bests rotate for seven days; Bedroom safety and reminders retain priority.
+# Notes: Shared parent/family phone pushes rotate for ten minutes; tags replace or clear matching entries.
+# Notes: The bounded notification feed uses sent timestamps, restores across restart, and prunes every 15 seconds.
+# Notes: Joanna Voice processing plays one cached spoken acknowledgment on the requesting Show; final speech stays in Assist.
+# Notes: joanna_voice_ack_url points to /local/joanna-voice/acknowledgement.mp3 on the local HA server.
# Video: https://youtu.be/ChgEu0IDWzc
######################################################################
@@ -58,6 +66,40 @@ timer:
icon: mdi:car-search
automation:
+ - alias: "Joanna Voice - Acknowledge Heard Request"
+ id: joanna_voice_acknowledge_heard_request
+ mode: parallel
+ max: 3
+ trigger:
+ - platform: state
+ entity_id:
+ - sensor.kitchen_show_voice_satellite
+ - sensor.upstairs_show_voice_satellite
+ - sensor.office_show_voice_satellite
+ to: "processing"
+ variables:
+ satellite_node: "{{ trigger.entity_id.removeprefix('sensor.').removesuffix('_voice_satellite') }}"
+ condition:
+ - condition: template
+ value_template: >-
+ {{ trigger.from_state is not none
+ and trigger.from_state.state in ['idle', 'listening']
+ and is_state('select.' ~ satellite_node ~ '_assistant', 'Joanna Voice') }}
+ action:
+ - service: "esphome.{{ satellite_node }}_announce"
+ data:
+ message: ""
+ url: !secret joanna_voice_ack_url
+ volume: 0
+ repeat: 0
+ repeat_pause: 0
+ chime: false
+ chime_file: ""
+ tts_engine: ""
+ tts_language: ""
+ tts_voice: ""
+ audio_only: true
+
- alias: "Bedroom Kiosk Tablet - Return to Kiosk View"
id: 11e50d2e-f66f-4fd0-86f4-bdca075c3a1c
mode: single
@@ -188,107 +230,113 @@ automation:
action:
- service: script.kitchen_kiosk_apply_camera_policy
- - alias: "Kitchen Kiosk Satellite - Volume Policy"
- id: cd67758d-88fd-4b57-bd50-3ab9c08c6dc0
+ - alias: "Kiosk Satellite - Reconcile Scheduled Policies"
+ id: ef9cfe77-5e14-4ef9-a369-a7c3fc97057c
+ description: "One daily/startup/reconnect fan-out preserves separate screen and volume policies."
mode: restart
- variables:
- kitchen_volume: "{{ 13 if is_state('group.bed', 'on') else 75 }}"
trigger:
- platform: time
at: "08:00:00"
- platform: homeassistant
event: start
+ - platform: state
+ entity_id:
+ - binary_sensor.kitchen_show_connectivity_2
+ - binary_sensor.upstairs_show_connectivity
+ - binary_sensor.office_show_connectivity
+ to: "on"
+ action:
+ - service: automation.trigger
+ target:
+ entity_id:
+ - automation.kitchen_kiosk_satellite_volume_policy
+ - automation.kitchen_kiosk_satellite_screen_policy
+ data:
+ skip_condition: false
+
+ - alias: "Kitchen Kiosk Satellite - Volume Policy"
+ id: cd67758d-88fd-4b57-bd50-3ab9c08c6dc0
+ description: "Kitchen follows bed occupancy; Upstairs and Office volume stay manual."
+ mode: restart
+ variables:
+ kiosk_volume: "{{ 13 if is_state('group.bed', 'on') else 75 }}"
+ trigger:
- platform: state
entity_id: group.bed
to: null
- - platform: state
- entity_id: binary_sensor.kitchen_show_connectivity_2
- to: "on"
condition:
- condition: state
entity_id: group.bed
- state:
- - "on"
- - "off"
- - condition: template
- value_template: >-
- {% set volume = states('number.kitchen_show_volume_2') %}
- {{ is_number(volume)
- and (volume | float(-1)) != (kitchen_volume | float) }}
+ state: ["on", "off"]
action:
- - service: number.set_value
- target:
- entity_id: number.kitchen_show_volume_2
- data:
- value: "{{ kitchen_volume }}"
+ - repeat:
+ for_each: >-
+ {% from 'kiosk_health.jinja' import satellite_devices %}
+ {{ satellite_devices() | from_json | selectattr('manage_volume', 'equalto', true) | list }}
+ sequence:
+ - variables:
+ volume_entity: "{{ 'number.' ~ repeat.item.node ~ '_volume' ~ repeat.item.suffix }}"
+ - if:
+ - condition: template
+ value_template: >-
+ {{ is_number(states(volume_entity))
+ and states(volume_entity) | float(-1) != kiosk_volume | float }}
+ then:
+ - service: number.set_value
+ continue_on_error: true
+ target:
+ entity_id: "{{ volume_entity }}"
+ data:
+ value: "{{ kiosk_volume }}"
- alias: "Kitchen Kiosk Satellite - Screen Policy"
id: 99d8d43b-9448-4916-b313-8117a038baaf
+ description: "Keep enrolled Shows reachable; use Dim instead of true panel-off."
mode: restart
trigger:
- - platform: time
- at: "08:00:00"
- - platform: homeassistant
- event: start
- platform: state
- entity_id: binary_sensor.kitchen_kiosk_should_be_on
- to: "on"
- - platform: state
- entity_id: light.kitchen_show_screen_2
- to: "on"
- - platform: state
- entity_id: light.kitchen_show_screen_2
- to: "off"
- - platform: state
- entity_id: binary_sensor.kitchen_show_connectivity_2
+ entity_id:
+ - binary_sensor.kitchen_kiosk_should_be_on
+ - light.kitchen_show_screen_2
+ - light.upstairs_show_screen
+ - light.office_show_screen
to: "on"
- platform: state
- entity_id: switch.kitchen_show_keep_screen_on_2
- to: "off"
- - platform: state
- entity_id: binary_sensor.kitchen_kiosk_should_be_on
+ entity_id:
+ - binary_sensor.kitchen_kiosk_should_be_on
+ - light.kitchen_show_screen_2
+ - switch.kitchen_show_keep_screen_on_2
+ - switch.upstairs_show_keep_screen_on
+ - switch.office_show_keep_screen_on
to: "off"
-
- action:
+ condition:
- condition: template
- value_template: >-
- {{ states('binary_sensor.kitchen_kiosk_should_be_on') in ['on', 'off'] }}
- - choose:
- - conditions:
- - condition: state
- entity_id: binary_sensor.kitchen_kiosk_should_be_on
- state: "on"
- sequence:
- # Keep Android from entering true panel-off and napping Wi-Fi.
- # Kiosk Satellite's Dim screensaver still owns idle brightness.
- - service: switch.turn_on
- target:
- entity_id: switch.kitchen_show_keep_screen_on_2
- - service: light.turn_on
- target:
- entity_id: light.kitchen_show_screen_2
- - service: button.press
- target:
- entity_id: button.kitchen_show_postpone_screensaver_2
- - service: script.kitchen_kiosk_apply_camera_policy
- default:
- # This Crown build drops Wi-Fi in true panel-off, so preserve the
- # reachable display session and let the Dim screensaver go dark.
- - service: switch.turn_on
- target:
- entity_id: switch.kitchen_show_keep_screen_on_2
+ value_template: "{{ states('binary_sensor.kitchen_kiosk_should_be_on') in ['on', 'off'] }}"
+ action:
+ - repeat:
+ for_each: >-
+ {% from 'kiosk_health.jinja' import satellite_devices %}
+ {{ satellite_devices() | from_json }}
+ sequence:
+ - if:
+ - condition: template
+ value_template: "{{ is_state('binary_sensor.' ~ repeat.item.node ~ '_connectivity' ~ repeat.item.suffix, 'on') }}"
+ then:
+ - service: script.kiosk_satellite_apply_screen_policy
+ data:
+ node: "{{ repeat.item.node }}"
+ suffix: "{{ repeat.item.suffix }}"
+ allow_idle_screen_off: "{{ repeat.item.allow_idle_screen_off | default(false) }}"
+ - if:
+ - condition: state
+ entity_id: binary_sensor.kitchen_kiosk_should_be_on
+ state: "on"
+ then:
+ - service: script.kitchen_kiosk_apply_camera_policy
+ else:
- service: timer.cancel
target:
entity_id: timer.kitchen_kiosk_detail_display
- - service: button.press
- target:
- entity_id: button.kitchen_show_close_camera_view_2
- - service: light.turn_on
- target:
- entity_id: light.kitchen_show_screen_2
- - service: switch.turn_on
- target:
- entity_id: switch.kitchen_show_screensaver_active_2
- alias: "Kitchen Kiosk Satellite - Daylight Radar Trigger"
id: 411dc0c0-a291-48e7-a493-2481a8d9477d
@@ -334,7 +382,7 @@ automation:
- alias: "Kitchen Kiosk Satellite - Refresh Systems Snapshot"
id: e920cab1-59d3-4d45-8aad-67170127504f
- description: "Recover the Kitchen page after HA startup and refresh the Systems screenshot."
+ description: "Recover enrolled Show pages after HA startup and refresh their Systems screenshots."
mode: single
max_exceeded: silent
trigger:
@@ -345,7 +393,7 @@ automation:
minutes: "/15"
id: periodic
action:
- - delay: "{{ '00:03:00' if trigger.id == 'startup' else '00:00:00' }}"
+ - delay: "{{ '00:03:00' if trigger is defined and (trigger.id | default('')) == 'startup' else '00:00:00' }}"
- choose:
- conditions:
- condition: trigger
@@ -356,98 +404,117 @@ automation:
entity_id: automation.kitchen_kiosk_satellite_daylight_radar_trigger
data:
skip_condition: false
- - condition: template
- value_template: >-
- {% set last_seen = as_timestamp(states('sensor.kitchen_show_last_seen_2'), 0) %}
- {{ last_seen > 0 and (as_timestamp(now()) - last_seen) < 300 }}
- - choose:
- - conditions:
- - condition: trigger
- id: startup
- sequence:
- - service: button.press
- target:
- entity_id: button.kitchen_show_reload_page_2
- - delay: "00:00:25"
- - service: button.press
- target:
- entity_id: button.kitchen_show_take_screenshot_2
+ - repeat:
+ for_each: >-
+ {% from 'kiosk_health.jinja' import satellite_devices %}
+ {{ satellite_devices() | from_json }}
+ sequence:
+ - if:
+ - condition: template
+ value_template: >-
+ {% set last_seen = as_timestamp(states('sensor.' ~ repeat.item.node ~ '_last_seen' ~ repeat.item.suffix), 0) %}
+ {{ last_seen > 0 and (as_timestamp(now()) - last_seen) < 300 }}
+ then:
+ - choose:
+ - conditions:
+ - condition: trigger
+ id: startup
+ sequence:
+ - action: button.press
+ target:
+ entity_id: "{{ 'button.' ~ repeat.item.node ~ '_reload_page' ~ repeat.item.suffix }}"
+ - delay: "00:00:25"
+ - action: button.press
+ target:
+ entity_id: "{{ 'button.' ~ repeat.item.node ~ '_take_screenshot' ~ repeat.item.suffix }}"
- alias: "Kitchen Kiosk Satellite - Update Joanna Dispatch"
id: 199e30f0-e28c-4842-953d-a65bc4b4e1d3
- description: "At 07:00, stage and dispatch Joanna daily while a Kiosk Satellite release remains pending for the Kitchen Show."
+ description: "At 07:00 update enrolled Shows, followers first, with bounded Joanna recovery."
mode: single
max_exceeded: silent
trigger:
- platform: time
at: "07:00:00"
+ action:
+ - repeat:
+ for_each: >-
+ {% from 'kiosk_health.jinja' import satellite_devices %}
+ {{ satellite_devices() | from_json }}
+ sequence:
+ - variables:
+ update_entity: "{{ 'update.' ~ repeat.item.node ~ '_update' ~ repeat.item.suffix }}"
+ - if:
+ - condition: template
+ value_template: >-
+ {% set installed = state_attr(update_entity, 'installed_version') | default('', true) | string %}
+ {% set latest = state_attr(update_entity, 'latest_version') | default('', true) | string %}
+ {{ is_state(update_entity, 'on')
+ and is_state('binary_sensor.' ~ repeat.item.node ~ '_connectivity' ~ repeat.item.suffix, 'on')
+ and not state_attr(update_entity, 'in_progress')
+ and installed not in ['', 'unknown', 'unavailable']
+ and latest not in ['', 'unknown', 'unavailable'] and installed != latest }}
+ then:
+ - service: script.kiosk_satellite_stage_update
+ data:
+ kiosk: "{{ repeat.item }}"
- condition:
- - condition: state
- entity_id: update.kitchen_show_update_2
- state: "on"
+template:
+ - triggers:
+ - trigger: event
+ event_type: call_service
+ event_data:
+ domain: notify
+ id: notification
+ - trigger: time_pattern
+ seconds: "/15"
+ id: expire
+ - trigger: homeassistant
+ event: start
+ id: startup
+ conditions:
- condition: template
value_template: >-
- {% set installed = state_attr('update.kitchen_show_update_2', 'installed_version') | default('', true) | string %}
- {% set latest = state_attr('update.kitchen_show_update_2', 'latest_version') | default('', true) | string %}
- {{ installed not in ['', 'unknown', 'unavailable']
- and latest not in ['', 'unknown', 'unavailable']
- and installed != latest }}
-
- action:
+ {{ trigger.id != 'notification'
+ or trigger.event.data.service in ['ios_parents', 'ios_family'] }}
+ actions:
- variables:
- installed_version: >-
- {{ state_attr('update.kitchen_show_update_2', 'installed_version') | default('unknown', true) }}
- latest_version: >-
- {{ state_attr('update.kitchen_show_update_2', 'latest_version') | default('unknown', true) }}
- release_url: >-
- {{ state_attr('update.kitchen_show_update_2', 'release_url') | default('not provided', true) }}
- structured_request: |-
- KITCHEN_KIOSK_SATELLITE_UPDATE_REQUEST
- target_device=Kitchen Show
- update_entity=update.kitchen_show_update_2
- installed_version={{ installed_version }}
- target_version={{ latest_version }}
- release_url={{ release_url }}
- authorization=This dispatch authorizes installing the pending Kiosk Satellite application update on the Kitchen Show only. Do not update LineageOS, Home Assistant, or unrelated applications and do not factory-reset or re-enroll the tablet.
- required_action=Home Assistant already invoked update.install and allowed two minutes for the signed installer payload to stage. If the update is still pending, use the pre-authorized Joanna ops helper /opt/codex/bin/kitchen-kiosk-update --expected-version {{ latest_version }} to complete this Kiosk Satellite update over the existing LAN-only management path.
- required_verification=Do not stop at a successful service call. Verify update.kitchen_show_update_2 reports installed_version equal to latest_version, in_progress is false, the Kitchen Show returns online, sensor.kitchen_show_current_page_2 is the expected kiosk dashboard, sensor.kitchen_show_remote_admin_2 is available, and a fresh screenshot can be requested.
- failure_policy=If the signed release cannot be installed safely or the device does not recover, stop without changing unrelated software and report the blocking evidence.
- - service: update.install
- continue_on_error: true
- target:
- entity_id: update.kitchen_show_update_2
- - delay: "00:02:00"
- - condition: state
- entity_id: update.kitchen_show_update_2
- state: "on"
- - service: script.send_to_logbook
- data:
- topic: "KIOSK"
- message: >-
- Home Assistant staged and Joanna dispatched Kitchen Show Kiosk Satellite from
- {{ installed_version }} to {{ latest_version }}.
- - service: script.joanna_dispatch
- data:
- trigger_context: "HA automation kitchen_kiosk_satellite_update_joanna_dispatch (Kitchen Kiosk Satellite - Update Joanna Dispatch)"
- source: "home_assistant_automation.kitchen_kiosk_satellite_update_dispatch"
- summary: >-
- Kiosk Satellite {{ latest_version }} is available for Kitchen Show;
- installed version is {{ installed_version }}.
- request: "{{ structured_request }}"
- entity_ids:
- - update.kitchen_show_update_2
- - sensor.kitchen_show_last_seen_2
- - sensor.kitchen_show_current_page_2
- - sensor.kitchen_show_remote_admin_2
- - button.kitchen_show_take_screenshot_2
- diagnostics: >-
- update_state={{ states('update.kitchen_show_update_2') }};
- in_progress={{ state_attr('update.kitchen_show_update_2', 'in_progress') }};
- last_seen={{ states('sensor.kitchen_show_last_seen_2') }};
- current_page={{ states('sensor.kitchen_show_current_page_2') }};
- remote_admin={{ states('sensor.kitchen_show_remote_admin_2') }}
-template:
+ notification_items: >-
+ {% set current = as_timestamp(now()) %}
+ {% set incoming = trigger.event.data.service_data if trigger.id == 'notification' else {} %}
+ {% set message = incoming.get('message', '') | string | striptags | regex_replace('\\s+', ' ') | trim %}
+ {% set title = incoming.get('title', '') | default('', true) | string | striptags | regex_replace('\\s+', ' ') | trim %}
+ {% set data = incoming.get('data', {}) | default({}, true) %}
+ {% set tag = data.get('tag', '') | default('', true) | string | trim %}
+ {% set key = 'tag:' ~ tag if tag else 'message:' ~ title ~ '|' ~ message %}
+ {% set clear = message == 'clear_notification' %}
+ {% set publish = trigger.id == 'notification' and message and not clear
+ and not message.startswith('command_') %}
+ {% set ns = namespace(items=[]) %}
+ {% for item in state_attr('sensor.kiosk_recent_phone_notifications', 'items') | default([], true) %}
+ {% if item is mapping and item.get('expires_at', 0) | float(0) > current
+ and not ((publish or (clear and tag)) and item.get('key') == key) %}
+ {% set ns.items = ns.items + [item] %}
+ {% endif %}
+ {% endfor %}
+ {% if publish %}
+ {% set icon = data.get('notification_icon', '') | default('', true) | string %}
+ {% set color = data.get('color', '') | default('', true) | string %}
+ {% set ns.items = ns.items + [dict(key=key, title=title or 'Household notification',
+ message=message | truncate(320, true), sent_at=current, expires_at=current + 600,
+ sent_time=now().strftime('%-I:%M %p'),
+ icon=icon if icon.startswith('mdi:') else 'mdi:message-text-outline',
+ color=color if color | regex_match('^#[0-9a-fA-F]{6}$') else '#1565c0')] %}
+ {% endif %}
+ {{ ns.items[-12:] }}
+ sensor:
+ - name: "Kiosk Recent Phone Notifications"
+ unique_id: kiosk_recent_phone_notifications
+ icon: mdi:message-text-clock-outline
+ state: "{{ notification_items | count }}"
+ attributes:
+ items: "{{ notification_items }}"
+
- sensor:
- name: "Kiosk Active Camera View"
unique_id: kiosk_active_camera_view
@@ -570,6 +637,12 @@ template:
EVERYTHING IS HANDLED
{% endif %}
attributes:
+ urgent: >-
+ {{ (state_attr('sensor.nws_alerts_alerts', 'Alerts') | default([], true) | count) > 0
+ or is_state('group.entry_points', 'on')
+ or states('group.locks') not in ['locked', 'unknown', 'unavailable']
+ or states('sensor.rheem_wh_alert_count') | int(0) > 0
+ or states('sensor.rheem_wh_available_hot_water') | float(100) < 30 }}
detail: >-
{% set alerts = state_attr('sensor.nws_alerts_alerts', 'Alerts') | default([], true) %}
{% set hot_water = states('sensor.rheem_wh_available_hot_water') | float(100) %}
@@ -730,130 +803,83 @@ template:
delay_on: "00:02:00"
delay_off: "00:00:30"
state: >-
- {% set invalid = ['unknown', 'unavailable', 'none', ''] %}
- {% set screenshot = as_timestamp(states('sensor.kitchen_show_last_screenshot_2'), 0) %}
- {% set page = states('sensor.kitchen_show_current_page_2') %}
- {% set remote_admin = states('sensor.kitchen_show_remote_admin_2') %}
- {% set connectivity = states('binary_sensor.kitchen_show_connectivity_2') %}
- {% set battery = states('sensor.kitchen_show_battery_2') | float(0) %}
- {% set device_offline = connectivity != 'on' %}
- {% set screenshot_stale = screenshot == 0 or (as_timestamp(now()) - screenshot) > 2700 %}
- {{ device_offline
- or page in invalid
- or remote_admin in invalid
- or '/dashboard-kiosk-satellite-staging/home' not in page
- or (battery < 20 and not is_state('binary_sensor.kitchen_show_charging_2', 'on'))
- or screenshot_stale }}
+ {% from 'kiosk_health.jinja' import satellite_status %}
+ {{ satellite_status('kitchen_show', '/dashboard-kiosk-satellite-staging/home', suffix='_2') | trim != 'online' }}
attributes:
status: >-
- {% set invalid = ['unknown', 'unavailable', 'none', ''] %}
- {% set screenshot = as_timestamp(states('sensor.kitchen_show_last_screenshot_2'), 0) %}
- {% set page = states('sensor.kitchen_show_current_page_2') %}
- {% set remote_admin = states('sensor.kitchen_show_remote_admin_2') %}
- {% set connectivity = states('binary_sensor.kitchen_show_connectivity_2') %}
- {% set battery = states('sensor.kitchen_show_battery_2') | float(0) %}
- {% if connectivity != 'on' %}
- offline
- {% elif page in invalid or remote_admin in invalid %}
- telemetry unavailable
- {% elif '/dashboard-kiosk-satellite-staging/home' not in page %}
- page drift
- {% elif battery < 20 and not is_state('binary_sensor.kitchen_show_charging_2', 'on') %}
- low battery
- {% elif screenshot == 0 or (as_timestamp(now()) - screenshot) > 2700 %}
- screenshot stale
- {% elif is_state('update.kitchen_show_update_2', 'on') %}
- update available
- {% else %}
- online
- {% endif %}
+ {% from 'kiosk_health.jinja' import satellite_status %}
+ {{ satellite_status('kitchen_show', '/dashboard-kiosk-satellite-staging/home', suffix='_2', include_update=true) | trim }}
+
+ - name: "Upstairs Kiosk Needs Attention"
+ unique_id: upstairs_kiosk_needs_attention
+ device_class: problem
+ icon: mdi:monitor-alert
+ delay_on: "00:02:00"
+ delay_off: "00:00:30"
+ state: >-
+ {% from 'kiosk_health.jinja' import satellite_status %}
+ {{ satellite_status('upstairs_show', '/dashboard-kiosk-satellite-staging/echo-show5', check_foreground=true) | trim != 'online' }}
+ attributes:
+ status: >-
+ {% from 'kiosk_health.jinja' import satellite_status %}
+ {{ satellite_status('upstairs_show', '/dashboard-kiosk-satellite-staging/echo-show5', check_foreground=true, include_update=true) | trim }}
+
+ - name: "Office Kiosk Needs Attention"
+ unique_id: office_kiosk_needs_attention
+ device_class: problem
+ icon: mdi:monitor-alert
+ delay_on: "00:02:00"
+ delay_off: "00:00:30"
+ state: >-
+ {% from 'kiosk_health.jinja' import satellite_status %}
+ {{ satellite_status('office_show', '/dashboard-kiosk-satellite-staging/echo-show5', check_foreground=true) | trim != 'online' }}
+ attributes:
+ status: >-
+ {% from 'kiosk_health.jinja' import satellite_status %}
+ {{ satellite_status('office_show', '/dashboard-kiosk-satellite-staging/echo-show5', check_foreground=true, include_update=true) | trim }}
- name: "Kiosk Devices Need Attention"
unique_id: kiosk_devices_need_attention
device_class: problem
icon: mdi:tablet-cellphone
state: >-
- {{ is_state('binary_sensor.bedroom_kiosk_needs_attention', 'on')
- or is_state('binary_sensor.kitchen_kiosk_needs_attention', 'on') }}
+ {% from 'kiosk_health.jinja' import attention_devices %}
+ {{ attention_devices() | from_json | count > 0 }}
attributes:
status: >-
- {% set bedroom = is_state('binary_sensor.bedroom_kiosk_needs_attention', 'on') %}
- {% set kitchen = is_state('binary_sensor.kitchen_kiosk_needs_attention', 'on') %}
- {% if bedroom and kitchen %}
- Bedroom and Kitchen need attention
- {% elif bedroom %}
- Bedroom needs attention
- {% elif kitchen %}
- Kitchen needs attention
- {% else %}
+ {% from 'kiosk_health.jinja' import attention_devices %}
+ {% set devices = attention_devices() | from_json %}
+ {% if devices | count == 0 %}
All kiosks online
+ {% elif devices | count == 1 %}
+ {{ devices[0] }} needs attention
+ {% else %}
+ {{ devices[:-1] | join(', ') }} and {{ devices[-1] }} need attention
{% endif %}
devices: >-
- {% set ns = namespace(items=[]) %}
- {% if is_state('binary_sensor.bedroom_kiosk_needs_attention', 'on') %}
- {% set ns.items = ns.items + ['Bedroom'] %}
- {% endif %}
- {% if is_state('binary_sensor.kitchen_kiosk_needs_attention', 'on') %}
- {% set ns.items = ns.items + ['Kitchen'] %}
- {% endif %}
- {{ ns.items }}
+ {% from 'kiosk_health.jinja' import attention_devices %}
+ {{ attention_devices() | from_json }}
script:
kitchen_kiosk_apply_camera_policy:
alias: "Kitchen Kiosk - Apply Camera Policy"
+ description: "Apply shared event priority to every enrolled native Show."
mode: restart
- variables:
- desired_view: >-
- {{ {'garage': 'Garage', 'front_door': 'Front Door', 'driveway': 'Driveway'}.get(
- states('sensor.kiosk_active_camera_view'), 'none') }}
- desired_button: >-
- {{ 'button.kitchen_show_show_garage_2'
- if desired_view == 'Garage'
- else 'button.kitchen_show_show_front_door_2' }}
sequence:
- - choose:
- - conditions:
- - condition: template
- value_template: "{{ desired_view in ['Garage', 'Front Door'] }}"
- sequence:
- - condition: template
- value_template: >-
- {{ states('sensor.kitchen_show_active_camera_view_2') != desired_view }}
- - service: script.kitchen_kiosk_show_event
- data:
- event_type: camera
- show_button: "{{ desired_button }}"
- - conditions:
- - condition: template
- value_template: "{{ desired_view == 'Driveway' }}"
- sequence:
- - choose:
- - conditions:
- - condition: template
- value_template: >-
- {{ states('sensor.kitchen_show_active_camera_view_2')
- not in ['none', 'unknown', 'unavailable'] }}
- sequence:
- - service: button.press
- target:
- entity_id: button.kitchen_show_close_camera_view_2
- - service: script.kitchen_kiosk_show_event
- data:
- event_type: detail
- default:
- - choose:
- - conditions:
- - condition: template
- value_template: >-
- {{ states('sensor.kitchen_show_active_camera_view_2')
- not in ['none', 'unknown', 'unavailable'] }}
- sequence:
- - service: button.press
- target:
- entity_id: button.kitchen_show_close_camera_view_2
- - service: button.press
- target:
- entity_id: button.kitchen_show_bring_to_front_2
+ - repeat:
+ for_each: >-
+ {% from 'kiosk_health.jinja' import satellite_devices %}
+ {{ satellite_devices() | from_json }}
+ sequence:
+ - if:
+ - condition: template
+ value_template: "{{ is_state('binary_sensor.' ~ repeat.item.node ~ '_connectivity' ~ repeat.item.suffix, 'on') }}"
+ then:
+ - service: script.kiosk_satellite_apply_camera_policy
+ data:
+ node: "{{ repeat.item.node }}"
+ suffix: "{{ repeat.item.suffix }}"
+ allow_idle_screen_off: "{{ repeat.item.allow_idle_screen_off | default(false) }}"
kitchen_kiosk_show_event:
alias: "Kitchen Kiosk - Show Display Event"
@@ -886,15 +912,7 @@ script:
and is_state('sun.sun', 'above_horizon')
and is_state('binary_sensor.kiosk_rain_radar_weather_event', 'on')
) }}
- - service: light.turn_on
- target:
- entity_id: light.kitchen_show_screen_2
- - service: button.press
- target:
- entity_id: button.kitchen_show_postpone_screensaver_2
- - service: button.press
- target:
- entity_id: button.kitchen_show_bring_to_front_2
+ - service: script.kiosk_satellite_wake_displays
- choose:
- conditions:
- condition: template
diff --git a/config/script/README.md b/config/script/README.md
index a1fbdae9..3db5cc9e 100755
--- a/config/script/README.md
+++ b/config/script/README.md
@@ -38,6 +38,8 @@ Reusable scripts that other automations call for notifications, lighting, safety
| [](interior_off.yaml) | One-call "all interior lights off" helper. |
### Household notification icons
+Shared phone notifications sent through `notify.ios_parents` or `notify.ios_family` also join the household kiosk rotation for ten minutes. The kiosk feed observes these group calls, including callers outside `notify_engine`, and preserves phone recipients, actions, attachments, and urgency. Each slide shows the notification title, message, and sent time. A repeated tag replaces its earlier slide; `clear_notification` removes the matching tag. Individual phone notifications and device commands stay out of the shared rotation.
+
[](https://youtu.be/TMfdxv-WprM) [](https://www.vcloudinfo.com/2026/10/home-assistant-ios-notification-icons.html)
Both `script.notify_engine` and `script.notify_engine_two_button` accept `notification_icon` (an `mdi:` name), `icon_url` (a public or `/local/` image), `notification_icon_color` (glyph hex color), and `color` (background hex color). On supported iOS apps, these appear as rounded sender icons and require a nonempty notification title. `icon_url` takes precedence over the MDI icon; colors apply only to MDI icons. See the [Companion App documentation](https://companion.home-assistant.io/docs/notifications/notifications-basic/#notification-icon-and-color) and [issue #1773](https://github.com/CCOSTAN/Home-AssistantConfig/issues/1773).
diff --git a/config/script/kiosk_satellite_apply_camera_policy.yaml b/config/script/kiosk_satellite_apply_camera_policy.yaml
new file mode 100644
index 00000000..d957766a
--- /dev/null
+++ b/config/script/kiosk_satellite_apply_camera_policy.yaml
@@ -0,0 +1,71 @@
+######################################################################
+# @CCOSTAN - Follow Me on X
+# For more info visit https://www.vcloudinfo.com/click-here
+# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
+# -------------------------------------------------------------------
+# Native Kiosk Satellite Fleet - Shared device policy
+# Enrolled device identity and route come from kiosk_health satellite_devices.
+# -------------------------------------------------------------------
+# Notes: Existing Kitchen script and automation identities remain compatible.
+######################################################################
+
+kiosk_satellite_apply_camera_policy:
+ alias: "Kiosk Satellite - Apply Camera Policy"
+ mode: parallel
+ max: 5
+ variables:
+ desired_view: "{{ {'garage': 'Garage', 'front_door': 'Front Door'}.get(states('sensor.kiosk_active_camera_view'), 'none') }}"
+ current_view: "{{ states('sensor.' ~ node ~ '_active_camera_view' ~ suffix) }}"
+ sequence:
+ - condition: template
+ value_template: >-
+ {{ is_state('light.' ~ node ~ '_screen' ~ suffix, 'on')
+ or states('sensor.kiosk_active_camera_view') in ['garage', 'front_door', 'driveway'] }}
+ - if:
+ - condition: template
+ value_template: "{{ desired_view in ['Garage', 'Front Door'] and current_view != desired_view }}"
+ then:
+ - service: light.turn_on
+ target:
+ entity_id: "{{ 'light.' ~ node ~ '_screen' ~ suffix }}"
+ - service: button.press
+ target:
+ entity_id:
+ - "{{ 'button.' ~ node ~ '_postpone_screensaver' ~ suffix }}"
+ - "{{ 'button.' ~ node ~ '_bring_to_front' ~ suffix }}"
+ - service: timer.cancel
+ target:
+ entity_id: timer.kitchen_kiosk_detail_display
+ - delay: "00:00:01"
+ - service: button.press
+ target:
+ entity_id: "{{ 'button.' ~ node ~ ('_show_garage' if desired_view == 'Garage' else '_show_front_door') ~ suffix }}"
+ else:
+ - if:
+ - condition: template
+ value_template: "{{ desired_view == 'none' and current_view not in ['none', 'unknown', 'unavailable'] }}"
+ then:
+ - service: button.press
+ target:
+ entity_id: "{{ 'button.' ~ node ~ '_close_camera_view' ~ suffix }}"
+ - if:
+ - condition: state
+ entity_id: sensor.kiosk_active_camera_view
+ state: driveway
+ then:
+ - service: light.turn_on
+ target:
+ entity_id: "{{ 'light.' ~ node ~ '_screen' ~ suffix }}"
+ - service: button.press
+ target:
+ entity_id: "{{ 'button.' ~ node ~ '_postpone_screensaver' ~ suffix }}"
+ - if:
+ - condition: template
+ value_template: >-
+ {{ not (allow_idle_screen_off | default(false))
+ or current_view not in ['none', 'unknown', 'unavailable']
+ or is_state('sensor.kiosk_active_camera_view', 'driveway') }}
+ then:
+ - service: button.press
+ target:
+ entity_id: "{{ 'button.' ~ node ~ '_bring_to_front' ~ suffix }}"
diff --git a/config/script/kiosk_satellite_apply_screen_policy.yaml b/config/script/kiosk_satellite_apply_screen_policy.yaml
new file mode 100644
index 00000000..d2a53551
--- /dev/null
+++ b/config/script/kiosk_satellite_apply_screen_policy.yaml
@@ -0,0 +1,55 @@
+######################################################################
+# @CCOSTAN - Follow Me on X
+# For more info visit https://www.vcloudinfo.com/click-here
+# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
+# -------------------------------------------------------------------
+# Native Kiosk Satellite Fleet - Shared device policy
+# Enrolled device identity and route come from kiosk_health satellite_devices.
+# -------------------------------------------------------------------
+# Notes: Existing Kitchen script and automation identities remain compatible.
+######################################################################
+
+kiosk_satellite_apply_screen_policy:
+ alias: "Kiosk Satellite - Apply Screen Policy"
+ mode: parallel
+ max: 5
+ sequence:
+ # A motion-capable kiosk owns its idle sleep; reconciliation must not wake it.
+ - condition: template
+ value_template: >-
+ {{ not (allow_idle_screen_off | default(false))
+ or is_state('light.' ~ node ~ '_screen' ~ suffix, 'on') }}
+ - service: switch.turn_on
+ continue_on_error: true
+ target:
+ entity_id: "{{ 'switch.' ~ node ~ '_keep_screen_on' ~ suffix }}"
+ - if:
+ - condition: template
+ value_template: "{{ not (allow_idle_screen_off | default(false)) }}"
+ then:
+ - service: light.turn_on
+ continue_on_error: true
+ target:
+ entity_id: "{{ 'light.' ~ node ~ '_screen' ~ suffix }}"
+ - if:
+ - condition: state
+ entity_id: binary_sensor.kitchen_kiosk_should_be_on
+ state: "on"
+ then:
+ - if:
+ - condition: template
+ value_template: "{{ not (allow_idle_screen_off | default(false)) }}"
+ then:
+ - service: button.press
+ continue_on_error: true
+ target:
+ entity_id: "{{ 'button.' ~ node ~ '_postpone_screensaver' ~ suffix }}"
+ else:
+ - service: button.press
+ continue_on_error: true
+ target:
+ entity_id: "{{ 'button.' ~ node ~ '_close_camera_view' ~ suffix }}"
+ - service: switch.turn_on
+ continue_on_error: true
+ target:
+ entity_id: "{{ 'switch.' ~ node ~ '_screensaver_active' ~ suffix }}"
diff --git a/config/script/kiosk_satellite_stage_update.yaml b/config/script/kiosk_satellite_stage_update.yaml
new file mode 100644
index 00000000..8cd9f5f4
--- /dev/null
+++ b/config/script/kiosk_satellite_stage_update.yaml
@@ -0,0 +1,80 @@
+######################################################################
+# @CCOSTAN - Follow Me on X
+# For more info visit https://www.vcloudinfo.com/click-here
+# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
+# -------------------------------------------------------------------
+# Native Kiosk Satellite Fleet - Shared device policy
+# Enrolled device identity and route come from kiosk_health satellite_devices.
+# -------------------------------------------------------------------
+# Notes: Existing Kitchen script and automation identities remain compatible.
+######################################################################
+
+kiosk_satellite_stage_update:
+ alias: "Kiosk Satellite - Stage Update and Dispatch Recovery"
+ description: "One enrolled device per call; Android app updates only."
+ mode: queued
+ max: 5
+ variables:
+ update_entity: "{{ 'update.' ~ kiosk.node ~ '_update' ~ kiosk.suffix }}"
+ installed_version: "{{ state_attr(update_entity, 'installed_version') }}"
+ latest_version: "{{ state_attr(update_entity, 'latest_version') }}"
+ release_url: "{{ state_attr(update_entity, 'release_url') | default('not provided', true) }}"
+ sequence:
+ - condition: template
+ value_template: >-
+ {% from 'kiosk_health.jinja' import satellite_devices %}
+ {{ kiosk in (satellite_devices() | from_json) }}
+ - service: update.install
+ continue_on_error: true
+ target:
+ entity_id: "{{ update_entity }}"
+ - delay: "00:02:00"
+ - service: button.press
+ continue_on_error: true
+ target:
+ entity_id: "{{ 'button.' ~ kiosk.node ~ '_take_screenshot' ~ kiosk.suffix }}"
+ - delay: "00:00:05"
+ - if:
+ - condition: template
+ value_template: >-
+ {{ state_attr(update_entity, 'installed_version') != latest_version
+ or state_attr(update_entity, 'in_progress')
+ or not is_state('binary_sensor.' ~ kiosk.node ~ '_connectivity' ~ kiosk.suffix, 'on')
+ or kiosk.path not in states('sensor.' ~ kiosk.node ~ '_current_page' ~ kiosk.suffix)
+ or states('sensor.' ~ kiosk.node ~ '_remote_admin' ~ kiosk.suffix) in ['unknown', 'unavailable', 'none', '']
+ or states('sensor.' ~ kiosk.node ~ '_voice_satellite') in ['unknown', 'unavailable', 'none', '']
+ or as_timestamp(states('sensor.' ~ kiosk.node ~ '_last_screenshot' ~ kiosk.suffix), 0) < as_timestamp(now()) - 60 }}
+ then:
+ - service: script.send_to_logbook
+ data:
+ topic: "KIOSK"
+ message: "{{ kiosk.name }} update {{ latest_version }} requires Joanna verification or installer recovery."
+ - service: script.joanna_dispatch
+ data:
+ trigger_context: "Daily enrolled Kiosk Satellite update"
+ source: "home_assistant_automation.kiosk_satellite_update_dispatch"
+ summary: "Verify {{ kiosk.name }} Kiosk Satellite update from {{ installed_version }} to {{ latest_version }}."
+ request: |-
+ KIOSK_SATELLITE_UPDATE_REQUEST
+ target_device={{ kiosk.name }}
+ device_key={{ kiosk.key }}
+ update_entity={{ update_entity }}
+ installed_version={{ installed_version }}
+ target_version={{ latest_version }}
+ release_url={{ release_url }}
+ expected_path={{ kiosk.path }}
+ authorization=Install the pending Kiosk Satellite app update on this enrolled device only. Preserve its identity, dashboard, login, fleet membership, local sleep settings and manual volume. No OS or unrelated software updates, factory resets or re-enrollment.
+ required_action=Home Assistant invoked update.install and waited two minutes. If still pending, use the authorized enrolled-device update helper with device_key={{ kiosk.key }} and expected_version={{ latest_version }}.
+ required_verification=Read installed_version, require target_version with in_progress false, device online, expected dashboard path, remote admin available, native voice listening, fleet membership retained and a fresh screenshot. A successful helper exit alone is insufficient.
+ failure_policy=Stop and report blocking evidence if the signed app payload or device cannot be recovered safely.
+ entity_ids:
+ - "{{ update_entity }}"
+ - "{{ 'sensor.' ~ kiosk.node ~ '_last_seen' ~ kiosk.suffix }}"
+ - "{{ 'sensor.' ~ kiosk.node ~ '_current_page' ~ kiosk.suffix }}"
+ - "{{ 'sensor.' ~ kiosk.node ~ '_remote_admin' ~ kiosk.suffix }}"
+ - "{{ 'sensor.' ~ kiosk.node ~ '_voice_satellite' }}"
+ - "{{ 'button.' ~ kiosk.node ~ '_take_screenshot' ~ kiosk.suffix }}"
+ diagnostics: >-
+ update_state={{ states(update_entity) }};
+ in_progress={{ state_attr(update_entity, 'in_progress') }};
+ current_page={{ states('sensor.' ~ kiosk.node ~ '_current_page' ~ kiosk.suffix) }}
diff --git a/config/script/kiosk_satellite_wake_displays.yaml b/config/script/kiosk_satellite_wake_displays.yaml
new file mode 100644
index 00000000..5a7d1ae3
--- /dev/null
+++ b/config/script/kiosk_satellite_wake_displays.yaml
@@ -0,0 +1,34 @@
+######################################################################
+# @CCOSTAN - Follow Me on X
+# For more info visit https://www.vcloudinfo.com/click-here
+# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
+# -------------------------------------------------------------------
+# Native Kiosk Satellite Fleet - Shared device policy
+# Enrolled device identity and route come from kiosk_health satellite_devices.
+# -------------------------------------------------------------------
+# Notes: Existing Kitchen script and automation identities remain compatible.
+######################################################################
+
+kiosk_satellite_wake_displays:
+ alias: "Kiosk Satellite - Wake Displays"
+ mode: restart
+ sequence:
+ - repeat:
+ for_each: >-
+ {% from 'kiosk_health.jinja' import satellite_devices %}
+ {{ satellite_devices() | from_json }}
+ sequence:
+ - if:
+ - condition: template
+ value_template: "{{ is_state('binary_sensor.' ~ repeat.item.node ~ '_connectivity' ~ repeat.item.suffix, 'on') }}"
+ then:
+ - service: light.turn_on
+ continue_on_error: true
+ target:
+ entity_id: "{{ 'light.' ~ repeat.item.node ~ '_screen' ~ repeat.item.suffix }}"
+ - service: button.press
+ continue_on_error: true
+ target:
+ entity_id:
+ - "{{ 'button.' ~ repeat.item.node ~ '_postpone_screensaver' ~ repeat.item.suffix }}"
+ - "{{ 'button.' ~ repeat.item.node ~ '_bring_to_front' ~ repeat.item.suffix }}"
diff --git a/config/www/bedroom-kiosk.html b/config/www/bedroom-kiosk.html
index a9d896cc..6d0a7e11 100644
--- a/config/www/bedroom-kiosk.html
+++ b/config/www/bedroom-kiosk.html
@@ -154,7 +154,8 @@
var stateEntities = [
'sensor.kiosk_active_camera_view',
'sensor.bedroom_kiosk_header',
- 'sensor.kiosk_household_alert'
+ 'sensor.kiosk_household_alert',
+ 'sensor.kiosk_recent_phone_notifications'
];
var states = {};
var imageUrls = {};
@@ -259,8 +260,21 @@
document.getElementById('header-detail').textContent = header.attributes.detail || '';
}
if (alert) {
- document.getElementById('alert-headline').textContent = alert.state;
- document.getElementById('alert-detail').textContent = alert.attributes.detail || '';
+ var slides = [{title: alert.state, detail: alert.attributes.detail || ''}];
+ var phoneFeed = states['sensor.kiosk_recent_phone_notifications'];
+ var notifications = phoneFeed && phoneFeed.attributes.items;
+ if (!alert.attributes.urgent && Array.isArray(notifications)) {
+ notifications.forEach(function (notification) {
+ if (Number(notification.expires_at) * 1000 <= Date.now()) return;
+ slides.push({
+ title: notification.title,
+ detail: notification.message + ' · Sent ' + notification.sent_time
+ });
+ });
+ }
+ var slide = slides[Math.floor(Date.now() / 10000) % slides.length];
+ document.getElementById('alert-headline').textContent = slide.title;
+ document.getElementById('alert-detail').textContent = slide.detail;
}
}
diff --git a/config/www/joanna-voice/README.md b/config/www/joanna-voice/README.md
new file mode 100644
index 00000000..e43332b3
--- /dev/null
+++ b/config/www/joanna-voice/README.md
@@ -0,0 +1,10 @@
+# Voice request acknowledgment
+
+`acknowledgement.mp3` says **Let me check on that.** It is a 2.16-second
+Home Assistant Cloud TTS clip using en-US JennyNeural, generated once for local
+playback. The kiosk package plays it without a chime or modal when an enrolled
+Joanna Voice satellite starts processing a heard request. Final speech remains
+in the Assist pipeline.
+
+Set the private `joanna_voice_ack_url` secret to this Home Assistant server's
+local HTTP URL ending in `/local/joanna-voice/acknowledgement.mp3`.
diff --git a/config/www/joanna-voice/acknowledgement.mp3 b/config/www/joanna-voice/acknowledgement.mp3
new file mode 100644
index 00000000..897fdf58
Binary files /dev/null and b/config/www/joanna-voice/acknowledgement.mp3 differ
diff --git a/tools/test_joanna_conversation.py b/tools/test_joanna_conversation.py
new file mode 100644
index 00000000..f7609115
--- /dev/null
+++ b/tools/test_joanna_conversation.py
@@ -0,0 +1,126 @@
+"""Exercise the actual voice HTTP client against an isolated loopback service."""
+
+import asyncio
+import importlib.util
+from pathlib import Path
+import unittest
+
+import aiohttp
+from aiohttp import web
+
+MODULE = Path(__file__).resolve().parents[1] / "config/custom_components/joanna_conversation/client.py"
+spec = importlib.util.spec_from_file_location("joanna_client", MODULE)
+client_module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(client_module)
+
+
+class VoiceClientTests(unittest.IsolatedAsyncioTestCase):
+ async def asyncSetUp(self):
+ self.calls = []
+ self.credentials = []
+ self.poll_count = 0
+ self.ready = True
+ self.lose_acceptance = False
+ self.unreadable = False
+
+ async def endpoint(request):
+ self.calls.append((request.method, request.path, await request.json() if request.can_read_body else None))
+ self.credentials.append(request.headers["Authorization"])
+ self.assertIn(request.headers["Authorization"], {"Bearer fixture-token", "Bearer upstairs-token", "Bearer office-token"})
+ if request.path.endswith("/status"):
+ return web.json_response({"ok": True, "deviceId": "kitchen-device"})
+ if request.path.endswith("/conversation"):
+ if self.unreadable:
+ return web.Response(text="{broken", content_type="application/json")
+ if self.lose_acceptance:
+ request.transport.close()
+ return web.Response()
+ return web.json_response({"status": "queued"}, status=202)
+ if request.path.endswith(("/ack", "/cancel")):
+ return web.json_response({"ok": True})
+ self.poll_count += 1
+ return web.json_response({"status": "complete" if self.ready else "queued", "text": "The exact Joanna answer."})
+
+ app = web.Application()
+ app.router.add_route("*", "/{path:.*}", endpoint)
+ self.runner = web.AppRunner(app)
+ await self.runner.setup()
+ site = web.TCPSite(self.runner, "127.0.0.1", 0)
+ await site.start()
+ self.session = aiohttp.ClientSession()
+ port = site._server.sockets[0].getsockname()[1]
+ self.client = client_module.JoannaClient(self.session, f"http://127.0.0.1:{port}", "fixture-token", "kitchen-device", wait_seconds=2, poll_seconds=0.01)
+
+ async def asyncTearDown(self):
+ await self.session.close()
+ await self.runner.cleanup()
+
+ async def test_exact_answer_reaches_speech_owner_and_receipt_follows_result(self):
+ await self.client.validate()
+ result = await self.client.converse("What is next?", "kitchen-device")
+ self.assertEqual(result, {"text": "The exact Joanna answer.", "success": True})
+ self.assertEqual(sum(path.endswith("/conversation") for _, path, _ in self.calls), 1)
+ self.assertTrue(self.calls[-1][1].endswith("/ack"))
+
+ async def test_unenrolled_device_cannot_submit(self):
+ for device in [None, "office-device"]:
+ with self.assertRaises(client_module.JoannaError):
+ await self.client.converse("Read my personal data", device)
+ self.assertEqual(self.calls, [])
+
+ async def test_shared_pipeline_selects_each_devices_own_credential_and_denies_unknowns(self):
+ bindings = [
+ {"device_id": "kitchen-device", "token": "fixture-token"},
+ {"device_id": "upstairs-device", "token": "upstairs-token"},
+ {"device_id": "office-device", "token": "office-token"},
+ ]
+ router = client_module.JoannaDeviceRouter(self.session, self.client.base_url, bindings)
+ for binding in bindings:
+ router.clients[binding["device_id"]].poll_seconds = 0.01
+ self.calls.clear()
+ self.credentials.clear()
+ result = await router.converse("Which profile is this?", binding["device_id"])
+ self.assertTrue(result["success"])
+ self.assertEqual(self.calls[0][2]["deviceId"], binding["device_id"])
+ self.assertEqual(set(self.credentials), {"Bearer " + binding["token"]})
+ self.calls.clear()
+ for device in [None, "unenrolled-device"]:
+ with self.assertRaises(client_module.JoannaError):
+ await router.converse("Read personal data", device)
+ self.assertEqual(self.calls, [])
+
+ async def test_lost_acceptance_is_reconciled_without_repeating_action(self):
+ self.lose_acceptance = True
+ result = await self.client.converse("Do this once", "kitchen-device")
+ self.assertTrue(result["success"])
+ self.assertEqual(sum(path.endswith("/conversation") for _, path, _ in self.calls), 1)
+ request_id = self.calls[0][2]["requestId"]
+ self.assertIn(("GET", f"/api/assist/requests/{request_id}", None), self.calls)
+
+ async def test_unreadable_acceptance_is_spoken_as_uncertain_without_resubmit(self):
+ self.unreadable = True
+ with self.assertRaisesRegex(client_module.JoannaError, "check the request's outcome"):
+ await self.client.converse("Do this once", "kitchen-device")
+ self.assertEqual(len(self.calls), 1)
+
+ async def test_timeout_requests_cancel_and_never_claims_action_failed_to_execute(self):
+ self.ready = False
+ self.client.wait_seconds = 0.05
+ with self.assertRaisesRegex(client_module.JoannaError, "check its status"):
+ await self.client.converse("Make an action", "kitchen-device")
+ self.assertTrue(self.calls[-1][1].endswith("/cancel"))
+ self.assertEqual(sum(path.endswith("/conversation") for _, path, _ in self.calls), 1)
+
+ async def test_pipeline_cancel_is_forwarded_to_existing_job(self):
+ self.ready = False
+ task = asyncio.create_task(self.client.converse("A long request", "kitchen-device"))
+ while not self.poll_count:
+ await asyncio.sleep(0.005)
+ task.cancel()
+ with self.assertRaises(asyncio.CancelledError):
+ await task
+ self.assertTrue(self.calls[-1][1].endswith("/cancel"))
+
+
+if __name__ == "__main__":
+ unittest.main()