Expand kiosk fleet policies, native voice, and temporary notifications

Share kiosk camera, screen, health and update policies across enrolled displays; add bounded native Assist transport and cached spoken acknowledgment. Shared phone notifications rotate for ten minutes with tag replacement and automatic expiry. Validation: 7 voice-client tests, dashboard and config checks, and physical notification delivery and expiry verified on all four kiosks.
codex/public-repo-cleanup
Carlo Costanzo 1 month ago
parent ed3f4ebb38
commit 0a9e16c96a

3
.gitignore vendored

@ -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/

@ -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.

@ -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)

@ -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

@ -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)),
}))

@ -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,
)

@ -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"
}

@ -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." }
}
}

@ -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 %}

@ -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

@ -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

@ -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;
}

File diff suppressed because it is too large Load Diff

@ -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

@ -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

@ -38,6 +38,8 @@ Reusable scripts that other automations call for notifications, lighting, safety
| [![YAML source: interior_off](https://img.shields.io/static/v1?label=YAML&message=interior_off&color=lightgrey&logo=github&logoColor=181717)](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.
[![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/TMfdxv-WprM) [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](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).

@ -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 }}"

@ -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 }}"

@ -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) }}

@ -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 }}"

@ -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;
}
}

@ -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`.

@ -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()
Loading…
Cancel
Save

Powered by TurnKey Linux.