Echo Show 8 et JARVIS : contourner les limites des skills Alexa
Un nouvel écran pour JARVIS
L’Echo Show 8 est mon nouveau jouet, et comme tout appareil connecté qui franchit le seuil de ma porte, il doit s’intégrer à mon système domotique « Jarvis ».
L’objectif est simple : en faire les oreilles et la bouche de mon installation, avec son bel écran en guise de visage.
Pour ce véritable banc d’essai, j’ai d’abord exploré la piste de LineageOS. Mais le constat a été sans appel: en flashant une ROM alternative, je perdais purement et simplement la fonction vocale native d’Alexa.
Alexa serait intégrée à la cuisine. Ainsi je pourrais suivre une recette sur Internet ou m’en servir de minuteur, m’accompagner avec de la musique tout en lui attribuant un second rôle crucial, devenir un assistant vocal pleinement connecté à Home Assistant.
Mes recherches initiales m’ont d’abord conduit vers la partie vocale en suivant ce tutoriel des Alexiens : Piloter Home Assistant à la voix grâce à Alexa.
Pour la partie visuelle, j’ai voulu tester la skill Home Assistant Dashboard. Comme beaucoup j’ai été déçu de voir que c’était bloqué, qu’Amazon avait durci sa politique ! Je me devais de trouver une solution.
Ma solution…
Le déclic est venu d’une question simple : pourquoi ne pas afficher directement une page Web ?
Pour cela je suis passé par AWS lambda pour répondre a une APL (Alexa Presentation Language).
Voici ma lambda_function.py
# -*- coding: utf-8 -*-
import logging
import ask_sdk_core.utils as ask_utils
from ask_sdk_core.skill_builder import SkillBuilder
from ask_sdk_core.dispatch_components import AbstractRequestHandler
from ask_sdk_core.dispatch_components import AbstractExceptionHandler
from ask_sdk_core.handler_input import HandlerInput
from ask_sdk_model.interfaces.alexa.presentation.apl import RenderDocumentDirective
from ask_sdk_model import Response
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
# ⚠️ Remplace par l'URL réelle de ton dashboard invité (Kiosk Mode)
DASHBOARD_URL = "LIEN_EXTERNE_HOME-ASSISTANT"
class LaunchRequestHandler(AbstractRequestHandler):
"""Handler for Skill Launch."""
def can_handle(self, handler_input):
return ask_utils.is_request_type("LaunchRequest")(handler_input)
def handle(self, handler_input):
speak_output = "Bienvenue sur Jarvis Home. Dis ouvre le tableau de bord pour afficher la maison."
return (
handler_input.response_builder
.speak(speak_output)
.ask(speak_output)
.response
)
class OuvrirDashboardIntentHandler(AbstractRequestHandler):
"""Handler pour ouvrir le dashboard Home Assistant via APL OpenURL."""
def can_handle(self, handler_input):
return ask_utils.is_intent_name("OuvrirDashboardIntent")(handler_input)
def handle(self, handler_input):
speak_output = "J'ouvre le tableau de bord."
apl_document = {
"type": "APL",
"version": "2023.3",
"mainTemplate": {
"items": [
{
"type": "Container",
"width": "100%",
"height": "100%",
"items": []
}
]
},
"onMount": [
{
"type": "OpenURL",
"source": DASHBOARD_URL
}
]
}
supported_interfaces = handler_input.request_envelope.context.system.device.supported_interfaces
if supported_interfaces.alexa_presentation_apl is not None:
handler_input.response_builder.add_directive(
RenderDocumentDirective(
token="dashboardToken",
document=apl_document
)
)
else:
speak_output = "Cet appareil ne supporte pas l'affichage visuel."
return (
handler_input.response_builder
.speak(speak_output)
.response
)
class HelpIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return ask_utils.is_intent_name("AMAZON.HelpIntent")(handler_input)
def handle(self, handler_input):
speak_output = "Dis ouvre le tableau de bord pour afficher la maison."
return (
handler_input.response_builder
.speak(speak_output)
.ask(speak_output)
.response
)
class CancelOrStopIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return (ask_utils.is_intent_name("AMAZON.CancelIntent")(handler_input) or
ask_utils.is_intent_name("AMAZON.StopIntent")(handler_input))
def handle(self, handler_input):
speak_output = "Au revoir !"
return (
handler_input.response_builder
.speak(speak_output)
.response
)
class FallbackIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return ask_utils.is_intent_name("AMAZON.FallbackIntent")(handler_input)
def handle(self, handler_input):
logger.info("In FallbackIntentHandler")
speech = "Je n'ai pas compris. Dis ouvre le tableau de bord ou aide."
reprompt = "Je n'ai pas compris. Que veux-tu faire ?"
return handler_input.response_builder.speak(speech).ask(reprompt).response
class SessionEndedRequestHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return ask_utils.is_request_type("SessionEndedRequest")(handler_input)
def handle(self, handler_input):
return handler_input.response_builder.response
class IntentReflectorHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return ask_utils.is_request_type("IntentRequest")(handler_input)
def handle(self, handler_input):
intent_name = ask_utils.get_intent_name(handler_input)
speak_output = "Tu viens de déclencher " + intent_name + "."
return (
handler_input.response_builder
.speak(speak_output)
.response
)
class CatchAllExceptionHandler(AbstractExceptionHandler):
def can_handle(self, handler_input, exception):
return True
def handle(self, handler_input, exception):
logger.error(exception, exc_info=True)
speak_output = "Désolé, j'ai rencontré un problème. Réessaie."
return (
handler_input.response_builder
.speak(speak_output)
.ask(speak_output)
.response
)
sb = SkillBuilder()
sb.add_request_handler(LaunchRequestHandler())
sb.add_request_handler(OuvrirDashboardIntentHandler())
sb.add_request_handler(HelpIntentHandler())
sb.add_request_handler(CancelOrStopIntentHandler())
sb.add_request_handler(FallbackIntentHandler())
sb.add_request_handler(SessionEndedRequestHandler())
sb.add_request_handler(IntentReflectorHandler())
sb.add_exception_handler(CatchAllExceptionHandler())
lambda_handler = sb.lambda_handler()
Dont voici le resultat :

Oui j’affiche un dashboard user, ou la navigation doit être intituitive et sans acces aux parametres avec kiosk-mode.
Le reveil
Après avoir fait de l’Echo Show 8 mon banc d’essai pour l’affichage et la voix, la suite logique s’est imposée : et si j’en faisais aussi un réveil intelligent ?
Je voulais reproduire l’effet des célèbres réveils d’aube type Philips Hue / Wake-up Light : une transition douce avec une lumière qui s’intensifie progressivement, accompagnée d’une musique agréable pour émerger en douceur.
Dans un premier temps j’ai fouiné sur le forum hacf.fr, où je me suis inspiré de plusieurs idées de dashboards.

Voici un aperçu du dashboard Lovelace : une entité pour activer l’alarme, un sélecteur d’heure, un bouton par jour de la semaine, et une zone snooze/stop qui n’apparaît que lorsque l’alarme est active.
type: grid
cards:
- type: vertical-stack
cards:
- type: entities
title: Configuration Réveil
show_header_toggle: false
entities:
- entity: input_boolean.reveil_actif
name: Activer l'alarme
- type: custom:time-picker-card
entity: input_datetime.reveil_heure
hour_mode: 24
hour_step: 1
minute_step: 1
layout:
hour_mode: double
align_controls: center
name: inside
- type: horizontal-stack
cards:
- type: custom:button-card
entity: input_boolean.reveil_lundi
name: Lun
show_icon: false
show_state: false
styles:
card:
- border-radius: 8px
- padding: 8px 4px
- font-size: 12px
- font-weight: 500
state:
- value: "on"
styles:
card:
- background: rgba(var(--rgb-primary-color), 0.25)
- border: 1px solid var(--primary-color)
name:
- color: var(--primary-color)
- value: "off"
styles:
card:
- background: rgba(128,128,128,0.1)
- border: 1px solid rgba(128,128,128,0.3)
name:
- color: var(--secondary-text-color)
tap_action:
action: toggle
# ... même structure répétée pour reveil_mardi (Mar) à reveil_dimanche (Dim),
# seuls "entity" et "name" changent d'un bouton à l'autre.
- type: conditional
conditions:
- entity: input_select.reveil_statut
state: Alarme en cours
card:
type: horizontal-stack
cards:
- type: custom:button-card
name: SNOOZE
icon: mdi:timer-sand
styles:
card:
- border-radius: 12px
tap_action:
action: perform-action
perform_action: script.reveil_snooze
- type: custom:button-card
name: STOP
icon: mdi:alarm-off
styles:
card:
- border-radius: 12px
- background: rgba(255, 0, 0, 0.2)
- border: 1px solid rgba(255, 0, 0, 0.5)
icon:
- color: red
tap_action:
action: perform-action
perform_action: script.reveil_stop
📄 Le YAML complet des 7 boutons jour est disponible sur le repo GitHub : reveil
Ce dashboard s’appuie sur quelques entités simples côté Home Assistant : une heure de réveil, un booléen par jour de la semaine, et un statut global qui pilote toute la machine à états.
input_datetime:
reveil_heure:
name: "Heure du réveil"
has_time: true
has_date: false
input_boolean:
reveil_actif:
name: "Réveil activé"
icon: mdi:alarm-check
reveil_lundi:
name: Lundi
reveil_mardi:
name: Mardi
reveil_mercredi:
name: Mercredi
reveil_jeudi:
name: Jeudi
reveil_vendredi:
name: Vendredi
reveil_samedi:
name: Samedi
reveil_dimanche:
name: Dimanche
input_select:
reveil_statut:
name: "Statut du réveil"
options:
- "Inactif"
- "Alarme en cours"
- "Snooze"
initial: "Inactif"
icon: mdi:alarm
Ces entités seules ne suffisent pas : il faut des scripts pour faire transiter le statut du réveil entre ses différents états (déclenchement, snooze, arrêt).
reveil_declenchement:
alias: "Réveil - Déclenchement"
sequence:
- action: input_select.select_option
target:
entity_id: input_select.reveil_statut
data:
option: "Alarme en cours"
- action: persistent_notification.create
data:
title: "⏰ Réveil !"
message: "Il est l'heure de se lever !"
notification_id: "reveil_notification"
reveil_snooze:
alias: "Réveil - Snooze (+5 min)"
sequence:
- action: input_select.select_option
target:
entity_id: input_select.reveil_statut
data:
option: "Snooze"
- action: persistent_notification.dismiss
data:
notification_id: "reveil_notification"
- delay:
minutes: 5
- condition: state
entity_id: input_select.reveil_statut
state: "Snooze"
- action: input_select.select_option
target:
entity_id: input_select.reveil_statut
data:
option: "Alarme en cours"
- action: persistent_notification.create
data:
title: "⏰ Réveil ! (Snooze terminé)"
message: "C'est vraiment l'heure cette fois !"
notification_id: "reveil_notification"
reveil_stop:
alias: "Réveil - Stop"
sequence:
- action: input_select.select_option
target:
entity_id: input_select.reveil_statut
data:
option: "Inactif"
- action: persistent_notification.dismiss
data:
notification_id: "reveil_notification"
Les automatisations YAML deviennent rapidement complexes lorsque plusieurs profils, horaires et comportements doivent cohabiter.
C’est précisément le rôle de mon moteur Python reveil_engine.py sous AppDaemon.
import appdaemon.plugins.hass.hassapi as hass
from datetime import datetime
# Courbe d'aurore par défaut : (offset_secondes, brightness_pct, rgb_color)
# Départ ambré très sombre -> blanc jour, sur 18 minutes.
DEFAULT_AURORE_CURVE = [
(0, 1, (120, 20, 10)),
(180, 8, (180, 60, 15)),
(360, 20, (255, 120, 40)),
(600, 40, (255, 180, 90)),
(840, 65, (255, 220, 160)),
(1080, 100, (255, 244, 229)),
]
# Configuration locale des médias (Puisque apps.yaml n'a pas de config)
MEDIAS_CONFIG = {
"doux": {
"media": "joue de la musique zen", # Recherche textuelle (ex: "musique zen", "lofi relax") ou URI Spotify
"provider": "spotify", # Service musical (ex: "spotify", "amazon_music") ou None
"type": "custom" # "custom" (simule une commande vocale) ou "playlist" (si URI direct)
},
"dynamique": {
"media": "joue de la musique hit du moment",
"provider": "spotify",
"type": "custom"
}
}
# Commande vocale simulée, testée et validée : ouvre directement Silk
# (le navigateur Silk est configuré avec le dashboard réveil en page d'accueil)
DASHBOARD_COMMAND = "ouvre Silk"
class ReveilEngine(hass.Hass):
def initialize(self):
self.repo = self.get_app("index_repository")
self.handles = []
self.volume_ramp_handle = None
# Résolution des actionneurs de la Chambre via l'index_repository
index = self.repo._load()
chambre = index["pieces"].get("Chambre", {})
actionneurs = {a["id"]: a["entity"] for a in chambre.get("actionneurs", [])}
self.plafonnier = actionneurs.get("Eclairage_Chambre")
self.chevet = actionneurs.get("Chevet_Chambre")
self.wled = actionneurs.get("Wled chambre")
# Configuration en dur (Puisque apps.yaml est vide)
self.media_player = "media_player.echo_show_cuisine" # Echo Show 8 - poste d'essai en cuisine
self.wled_effect = "Audio Reactive"
self.aurore_curve = DEFAULT_AURORE_CURVE
self.volume_doux_max = 0.55
self.volume_dynamique = 0.6
self.reveil_lock = "input_boolean.reveil_gemma_lock"
# Association de l'objet de configuration défini en haut du script
self.medias = MEDIAS_CONFIG
self.listen_state(self.on_statut_change, "input_select.reveil_statut")
self.log(
f"ReveilEngine prêt (Configuration 100% interne) — "
f"plafonnier={self.plafonnier}, chevet={self.chevet}, wled={self.wled}"
)
# ------------------------------------------------------------------
# Déclenchement
# ------------------------------------------------------------------
def on_statut_change(self, entity, attribute, old, new, kwargs):
if new == "Alarme en cours":
self.start_sequence()
elif new in ("Inactif", "Snooze"):
self.cancel_sequence()
if new == "Inactif":
self.stop_all_outputs()
def start_sequence(self):
self.cancel_sequence() # anti double-déclenchement
weekday = datetime.now().weekday() # 0=lundi ... 5=samedi, 6=dimanche
if weekday >= 5:
self.log("Réveil déclenché — profil doux (aurore)")
self.run_profil_doux()
else:
self.log("Réveil déclenché — profil dynamique")
self.run_profil_dynamique()
def _open_dashboard(self, kwargs=None):
self.call_service(
"media_player/play_media",
entity_id=self.media_player,
media_content_type="custom",
media_content_id=DASHBOARD_COMMAND,
)
# ------------------------------------------------------------------
# Profil doux (week-end) — gradation aurore + musique zen
# ------------------------------------------------------------------
def run_profil_doux(self):
# Lock : GEMMA cède la main
self.turn_on(self.reveil_lock)
for offset, brightness, rgb in self.aurore_curve:
h = self.run_in(
self._apply_aurore_step, offset,
brightness=brightness, rgb=rgb,
)
self.handles.append(h)
# Musique d'abord (l'audio continue en fond), puis reprise de l'écran
# par Silk quelques secondes après — sinon l'écran "En cours de lecture"
# de Spotify écrase le dashboard réveil et masque Snooze/Stop.
h = self.run_in(self._start_zen_music, 0)
self.handles.append(h)
h = self.run_in(self._open_dashboard, 4)
self.handles.append(h)
def _apply_aurore_step(self, kwargs):
brightness = kwargs["brightness"]
rgb = kwargs["rgb"]
for light in (self.plafonnier, self.chevet):
if light:
self.turn_on(
light,
brightness_pct=brightness,
rgb_color=rgb,
transition=20,
)
def _start_zen_music(self, kwargs):
config_media = self.medias.get("doux")
if not config_media:
return
self._play_media(config_media)
self._set_volume(0.15)
# Montée de volume progressive, calée sur la durée de la courbe aurore
duree_totale = self.aurore_curve[-1][0]
nb_paliers = max(duree_totale / 60, 1)
step = (self.volume_doux_max - 0.15) / nb_paliers
handle = self.run_every(self._ramp_volume, "now", 60, step=step)
self.volume_ramp_handle = handle
self.handles.append(handle)
def _ramp_volume(self, kwargs):
step = kwargs.get("step", 0.05)
current = self.get_state(self.media_player, attribute="volume_level")
if current is None:
# État indisponible à ce tick — on retente au prochain
return
current = float(current)
if current >= self.volume_doux_max:
self._stop_volume_ramp()
return
nouveau_volume = min(current + step, self.volume_doux_max)
self._set_volume(nouveau_volume)
if nouveau_volume >= self.volume_doux_max:
self._stop_volume_ramp()
def _stop_volume_ramp(self):
if self.volume_ramp_handle:
self.cancel_timer(self.volume_ramp_handle)
if self.volume_ramp_handle in self.handles:
self.handles.remove(self.volume_ramp_handle)
self.volume_ramp_handle = None
# ------------------------------------------------------------------
# Profil dynamique (semaine) — WLED + musique rythmée, sans les Hue
# ------------------------------------------------------------------
def run_profil_dynamique(self):
if self.wled:
self.call_service(
"light/turn_on",
entity_id=self.wled,
effect=self.wled_effect,
brightness_pct=100,
)
config_media = self.medias.get("dynamique")
if config_media:
# Musique d'abord, puis reprise de l'écran par Silk (même logique que le profil doux)
h = self.run_in(self._start_dynamique_music, 0, config_media=config_media)
self.handles.append(h)
h = self.run_in(self._open_dashboard, 4)
self.handles.append(h)
def _start_dynamique_music(self, kwargs):
config_media = kwargs["config_media"]
self._play_media(config_media)
self._set_volume(self.volume_dynamique)
# ------------------------------------------------------------------
# Moteur Audio / Alexa
# ------------------------------------------------------------------
def _play_media(self, media_config):
"""Lance la lecture sur Alexa en fonction de la configuration de l'objet."""
media_id = media_config.get("media")
media_type = media_config.get("type", "custom")
provider = media_config.get("provider")
if not media_id:
return
call_kwargs = {
"entity_id": self.media_player,
"media_content_type": media_type
}
if media_type == "custom":
# Demande vocale simulée (ex: "joue musique zen sur spotify")
commande = f"joue {media_id}"
if provider:
commande += f" sur {provider}"
call_kwargs["media_content_id"] = commande
else:
# Mode classique (ex: "playlist" pour un URI direct de playlist Spotify)
call_kwargs["media_content_id"] = media_id
if provider:
call_kwargs["extra"] = {"preferred_music_provider": provider}
self.call_service("media_player/play_media", **call_kwargs)
def _set_volume(self, volume_level):
self.call_service(
"media_player/volume_set",
entity_id=self.media_player,
volume_level=round(volume_level, 3),
)
# ------------------------------------------------------------------
# Annulation / arrêt
# ------------------------------------------------------------------
def cancel_sequence(self):
for h in self.handles:
try:
self.cancel_timer(h)
except Exception:
pass
self.handles = []
self.volume_ramp_handle = None
self.turn_off(self.reveil_lock)
def stop_all_outputs(self):
if self.wled:
self.turn_off(self.wled)
self.call_service("media_player/media_pause", entity_id=self.media_player)
Deux profils, une même logique d’état
Le YAML pur montre vite ses limites dès qu’on veut gérer plusieurs profils, une courbe de gradation progressive et une synchronisation audio/lumière — d’où reveil_engine.py, qui prend le relais dès que le statut passe à “Alarme en cours”.
Le moteur applique un profil selon le jour de la semaine :
- Profil doux (week-end) : une courbe d’aurore en 6 paliers fait monter progressivement la luminosité et la température de couleur des Hue sur 18 minutes, pendant qu’une musique zen démarre en fond à faible volume puis remonte par paliers de 60 secondes jusqu’à un plafond.
- Profil dynamique (semaine) : le ruban WLED bascule en mode “Audio Reactive” à pleine luminosité, couplé à une musique plus rythmée à volume fixe — l’objectif ici n’est pas la douceur, mais de sortir du sommeil rapidement.
Dans les deux cas, la musique démarre d’abord, puis Silk est rappelé pour afficher le dashboard réveil — ce léger décalage évite que l’écran “lecture en cours” de Spotify n’écrase les boutons Snooze/Stop.
Restait une nuance à régler : que se passe-t-il si l’Echo Show n’est pas déjà sur la vue réveil au moment du déclenchement ? Deux cas de figure :
- Si l’écran affichait déjà la vue réveil, elle est simplement rappelée par la commande vocale “ouvre Silk”.
- Si l’écran était sur l’accueil Alexa, j’ai transformé le badge réveil du dashboard (visible uniquement quand l’alarme est active) en lien direct vers la vue réveil — un tap suffit pour y accéder.
Résultat : Snooze et Stop restent toujours accessibles en un geste, quel que soit l’état de l’écran au moment où l’alarme se déclenche.
L’avenir
L’achat d’un Echo Show 5 pour la chambre en guise de reveil et pourquoi pas intégrer des boutons physiques pour Stop/Snooze dans un support…