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
parent
ed3f4ebb38
commit
0a9e16c96a
@ -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 %}
|
||||
@ -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
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
|
||||
@ -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 }}"
|
||||
@ -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`.
|
||||
Binary file not shown.
@ -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…
Reference in new issue