feat(notifier): push state events to Smart Home Tablet overlay
Adds a thin HTTP bridge so the tablet at https://tablet.digital-home.site shows a Siri-style overlay reflecting the current assistant state (wake / command / response / idle / error). Non-fatal: if the tablet is offline or TABLET_URL/VOICE_API_KEY are unset, events are silently skipped and the assistant keeps working. - satellite/notifier.py — POST /api/voice/event with bearer token, reused requests.Session for keep-alive, 1.5s timeout - satellite/modes.py — emits wake on activation, command after STT, response after LLM, idle on timeout - satellite/llm.py — emits error on gateway connection/timeout/HTTP - .env.example documents TABLET_URL and VOICE_API_KEY Tablet side (separate repo smart-home-tablet, commit 51c3d60) exposes POST /api/voice/event + GET /api/voice/stream (SSE) and renders a full-screen overlay in components/VoiceOverlay.tsx. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,3 +47,10 @@ LOG_FILE=errors.log
|
|||||||
|
|
||||||
COSMO_SESSION_KEY=agent:voice:voice:home
|
COSMO_SESSION_KEY=agent:voice:voice:home
|
||||||
LUSYA_SESSION_KEY=agent:wife:voice:home
|
LUSYA_SESSION_KEY=agent:wife:voice:home
|
||||||
|
|
||||||
|
# Smart Home Tablet integration (опционально)
|
||||||
|
# Если настроено — скрипт шлёт события состояния (wake/command/response/idle/error)
|
||||||
|
# на планшет, который показывает оверлей с Siri-blob + распознанным текстом.
|
||||||
|
# Если не настроено, просто пропускается, ассистент работает как раньше.
|
||||||
|
TABLET_URL=https://tablet.digital-home.site
|
||||||
|
VOICE_API_KEY=your_voice_api_key_here
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import requests
|
|||||||
from .config import AGENTS, VOICE_MAX_TOKENS, LLM_RETRIES, log
|
from .config import AGENTS, VOICE_MAX_TOKENS, LLM_RETRIES, log
|
||||||
from .text import clean_for_speech, find_sentence_end
|
from .text import clean_for_speech, find_sentence_end
|
||||||
from .tts import speak, play_error_sound
|
from .tts import speak, play_error_sound
|
||||||
|
from . import notifier
|
||||||
|
|
||||||
VOICE_SESSION_KEY = os.getenv("VOICE_SESSION_KEY", "agent:main:voice:home")
|
VOICE_SESSION_KEY = os.getenv("VOICE_SESSION_KEY", "agent:main:voice:home")
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ def ask_agent_stream(text: str, agent_id: str = "cosmo") -> str:
|
|||||||
msg = "Не могу связаться с сервером, попробуй ещё раз."
|
msg = "Не могу связаться с сервером, попробуй ещё раз."
|
||||||
print(f"⚠️ {msg}")
|
print(f"⚠️ {msg}")
|
||||||
play_error_sound()
|
play_error_sound()
|
||||||
|
notifier.error(msg, agent_id)
|
||||||
_maybe_speak(msg)
|
_maybe_speak(msg)
|
||||||
return msg
|
return msg
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
@@ -98,6 +100,7 @@ def ask_agent_stream(text: str, agent_id: str = "cosmo") -> str:
|
|||||||
msg = "Сервер не ответил вовремя, попробуй ещё раз."
|
msg = "Сервер не ответил вовремя, попробуй ещё раз."
|
||||||
print(f"⚠️ {msg}")
|
print(f"⚠️ {msg}")
|
||||||
play_error_sound()
|
play_error_sound()
|
||||||
|
notifier.error(msg, agent_id)
|
||||||
_maybe_speak(msg)
|
_maybe_speak(msg)
|
||||||
return msg
|
return msg
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
@@ -107,6 +110,7 @@ def ask_agent_stream(text: str, agent_id: str = "cosmo") -> str:
|
|||||||
msg = "Ошибка сервера, попробуй ещё раз."
|
msg = "Ошибка сервера, попробуй ещё раз."
|
||||||
print(f"⚠️ Gateway {status}: {body[:200]}")
|
print(f"⚠️ Gateway {status}: {body[:200]}")
|
||||||
play_error_sound()
|
play_error_sound()
|
||||||
|
notifier.error(msg, agent_id)
|
||||||
_maybe_speak(msg)
|
_maybe_speak(msg)
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .config import GATEWAY_URL, AGENTS, FOLLOWUP_TIMEOUT, MAX_DURATION, log
|
|||||||
from .audio import record
|
from .audio import record
|
||||||
from .tts import speak, stop_speaking
|
from .tts import speak, stop_speaking
|
||||||
from .llm import ask_agent_stream, is_reset_command, VOICE_SESSION_KEY
|
from .llm import ask_agent_stream, is_reset_command, VOICE_SESSION_KEY
|
||||||
|
from . import notifier
|
||||||
|
|
||||||
WAKE_THRESHOLD = float(os.getenv("WAKE_THRESHOLD", "0.5"))
|
WAKE_THRESHOLD = float(os.getenv("WAKE_THRESHOLD", "0.5"))
|
||||||
|
|
||||||
@@ -47,15 +48,18 @@ def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"):
|
|||||||
text = record(initial_silence_timeout=timeout)
|
text = record(initial_silence_timeout=timeout)
|
||||||
if not text:
|
if not text:
|
||||||
print("😴 Тишина, жду активации...\n")
|
print("😴 Тишина, жду активации...\n")
|
||||||
|
notifier.idle()
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"📝 Ты → {agent_name}: {text}")
|
print(f"📝 Ты → {agent_name}: {text}")
|
||||||
|
notifier.command(text, agent_id)
|
||||||
|
|
||||||
if _handle_reset(text, agent_id):
|
if _handle_reset(text, agent_id):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
response = ask_agent_stream(text, agent_id=agent_id)
|
response = ask_agent_stream(text, agent_id=agent_id)
|
||||||
print(f"🤖 {agent_name}: {response}\n")
|
print(f"🤖 {agent_name}: {response}\n")
|
||||||
|
notifier.response(response, agent_id)
|
||||||
|
|
||||||
|
|
||||||
def run_with_enter():
|
def run_with_enter():
|
||||||
@@ -67,6 +71,7 @@ def run_with_enter():
|
|||||||
try:
|
try:
|
||||||
input("⏎ Нажми Enter и говори...")
|
input("⏎ Нажми Enter и говори...")
|
||||||
stop_speaking() # barge-in
|
stop_speaking() # barge-in
|
||||||
|
notifier.wake("cosmo")
|
||||||
_conversation_loop("cosmo", "Cosmo")
|
_conversation_loop("cosmo", "Cosmo")
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -110,7 +115,9 @@ def run_with_porcupine():
|
|||||||
print(f"PREDICTION cosmo: {cosmo_score:.3f}")
|
print(f"PREDICTION cosmo: {cosmo_score:.3f}")
|
||||||
|
|
||||||
if cosmo_score > WAKE_THRESHOLD:
|
if cosmo_score > WAKE_THRESHOLD:
|
||||||
|
print("✅ Услышал 'Космо'!")
|
||||||
stop_speaking() # на случай если TTS ещё играет
|
stop_speaking() # на случай если TTS ещё играет
|
||||||
|
notifier.wake("cosmo")
|
||||||
stream.stop_stream()
|
stream.stop_stream()
|
||||||
_conversation_loop("cosmo", "Cosmo")
|
_conversation_loop("cosmo", "Cosmo")
|
||||||
cosmo_model.reset()
|
cosmo_model.reset()
|
||||||
|
|||||||
60
satellite/notifier.py
Normal file
60
satellite/notifier.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Tablet notifier — пересылает состояния ассистента в Smart Home Tablet
|
||||||
|
(https://tablet.digital-home.site/api/voice/event).
|
||||||
|
|
||||||
|
Планшет показывает оверлей (Siri-blob, распознанный текст, ответ).
|
||||||
|
Не критичный слой: любые сетевые ошибки глотаются, ассистент продолжает
|
||||||
|
работать даже если планшет оффлайн / не настроен.
|
||||||
|
|
||||||
|
Активируется только когда заполнены TABLET_URL и VOICE_API_KEY в .env.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .config import log
|
||||||
|
|
||||||
|
TABLET_URL = os.getenv("TABLET_URL", "").rstrip("/")
|
||||||
|
VOICE_API_KEY = os.getenv("VOICE_API_KEY", "")
|
||||||
|
|
||||||
|
# Переиспользуем HTTP сессию (keep-alive) для минимума latency
|
||||||
|
_session = requests.Session()
|
||||||
|
|
||||||
|
_ENABLED = bool(TABLET_URL and VOICE_API_KEY)
|
||||||
|
if _ENABLED:
|
||||||
|
print(f"🔔 Notifier: планшет {TABLET_URL}")
|
||||||
|
else:
|
||||||
|
print("🔕 Notifier: отключён (нет TABLET_URL или VOICE_API_KEY в .env)")
|
||||||
|
|
||||||
|
|
||||||
|
def _send(event: str, **payload):
|
||||||
|
if not _ENABLED:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_session.post(
|
||||||
|
f"{TABLET_URL}/api/voice/event",
|
||||||
|
json={"event": event, **payload},
|
||||||
|
headers={"Authorization": f"Bearer {VOICE_API_KEY}"},
|
||||||
|
timeout=1.5,
|
||||||
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
log.debug("Tablet notify failed (non-fatal)", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def wake(agent_id: str):
|
||||||
|
_send("wake", agent=agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
def command(text: str, agent_id: str):
|
||||||
|
_send("command", text=text, agent=agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
def response(text: str, agent_id: str):
|
||||||
|
_send("response", text=text, agent=agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
def idle():
|
||||||
|
_send("idle")
|
||||||
|
|
||||||
|
|
||||||
|
def error(text: str, agent_id: str = "cosmo"):
|
||||||
|
_send("error", text=text, agent=agent_id)
|
||||||
Reference in New Issue
Block a user