Files
Amazon-Alexa-Skills/home-assistant-skill/factories.js
T

259 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---- Wiederverwendbare Intent-Factories ----
// Statt für jedes neue Gerät/jeden neuen Sensor einen kompletten Handler zu
// kopieren, reicht hier ein kurzer Config-Aufruf. Neue Intents müssen weiterhin
// in der Alexa Developer Console angelegt werden (Intent-Name + Sample Utterances) -
// das lässt sich nicht vermeiden, unabhängig vom Backend.
const Alexa = require('ask-sdk-core');
const { getHaState } = require('./ha-client');
const UNKNOWN_VALUES = ['unknown', 'unavailable', 'none'];
const INACTIVE_STATUS_VALUES = ['fertig', 'inaktiv', 'finished', 'inactive'];
/** Extrahiert die aufgelöste Slot-ID und den sauberen gesprochenen Namen. */
function resolveSlot(slot, fallbackText) {
const resolvedValue = slot?.resolutions?.resolutionsPerAuthority?.[0]?.values?.[0]?.value;
return {
id: resolvedValue?.id,
name: resolvedValue?.name || slot?.value || fallbackText,
};
}
/**
* Baut einen Intent-Handler für Geräte mit optionalem Status/Fortschritt/Endzeit
* (z.B. Waschmaschine, Trockner, Geschirrspüler).
*
* @param {object} config
* @param {string} config.intentName - Intent-Name aus der Alexa Console, z.B. "GetDryerIntent"
* @param {string} config.deviceName - Wie das Gerät in der Antwort genannt wird, z.B. "Der Trockner"
* @param {string} [config.statusEntity] - Optional. Entity mit Betriebszustand (z.B. "fertig"/"inaktiv")
* @param {string} [config.progressEntity] - Optional. Entity mit Fortschritt in Prozent
* @param {string} [config.endTimeEntity] - Optional. Entity mit ISO-Zeitstempel der Endzeit
* @param {function} [config.customStatusTemplate] - Optional. (state: string) => string
* Wird verwendet, wenn NUR statusEntity gesetzt ist (kein progressEntity), um eine
* individuelle Formulierung statt der generischen "ist im Zustand X" zu erzeugen.
*/
function makeApplianceIntent({
intentName,
deviceName,
statusEntity,
progressEntity,
endTimeEntity,
customStatusTemplate,
}) {
return {
canHandle(handlerInput) {
return (
Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' &&
Alexa.getIntentName(handlerInput.requestEnvelope) === intentName
);
},
async handle(handlerInput) {
try {
const statusState = statusEntity
? await getHaState(statusEntity).catch(() => null)
: null;
const betrieb = statusState?.state?.toLowerCase().trim();
if (betrieb && INACTIVE_STATUS_VALUES.includes(betrieb)) {
return handlerInput.responseBuilder
.speak(`${deviceName} läuft aktuell nicht.`)
.getResponse();
}
if (!progressEntity) {
let speakOutput;
if (statusState && customStatusTemplate) {
speakOutput = customStatusTemplate(statusState.state);
} else if (statusState) {
speakOutput = `${deviceName} ist im Zustand ${statusState.state}.`;
} else {
speakOutput = `Für ${deviceName} habe ich leider keine Informationen.`;
}
return handlerInput.responseBuilder.speak(speakOutput).getResponse();
}
const progressState = await getHaState(progressEntity);
const progress = progressState.state;
if (UNKNOWN_VALUES.includes(progress)) {
return handlerInput.responseBuilder
.speak(`${deviceName} läuft aktuell nicht.`)
.getResponse();
}
const roundedProgress = Math.round(parseFloat(progress));
if (!endTimeEntity) {
return handlerInput.responseBuilder
.speak(`${deviceName} ist ${roundedProgress} Prozent fertig.`)
.getResponse();
}
const endTimeState = await getHaState(endTimeEntity);
const endTimeRaw = endTimeState.state;
if (UNKNOWN_VALUES.includes(endTimeRaw)) {
return handlerInput.responseBuilder
.speak(`${deviceName} ist ${roundedProgress} Prozent fertig.`)
.getResponse();
}
const endTime = new Date(endTimeRaw);
const now = new Date();
if (endTime.getTime() - now.getTime() <= 0) {
return handlerInput.responseBuilder.speak(`${deviceName} ist fertig.`).getResponse();
}
const timeFormatted = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Berlin',
}).format(endTime);
const speakOutput = `${deviceName} ist ${roundedProgress} Prozent fertig und endet um ${timeFormatted} Uhr.`;
return handlerInput.responseBuilder.speak(speakOutput).getResponse();
} catch (err) {
console.error(`HA API Fehler (${intentName}):`, err);
return handlerInput.responseBuilder
.speak('Ich konnte gerade keine Verbindung zu Home Assistant herstellen. Bitte versuch es später noch einmal.')
.getResponse();
}
},
};
}
/**
* Baut einen Intent-Handler für einen einzelnen Sensor-Wert ohne Slot
* (z.B. aktueller Stromverbrauch, Luftfeuchtigkeit, Füllstand).
*
* @param {object} config
* @param {string} config.intentName - Intent-Name aus der Alexa Console
* @param {string} config.entityId - Home-Assistant-Entity-ID
* @param {function} config.template - (value, unit, state) => string
* @param {function} [config.transformValue] - (rawValue: string) => string|number
* @param {string} [config.notFoundMessage] - Antwort, falls Wert unknown/unavailable ist.
*/
function makeSimpleSensorIntent({
intentName,
entityId,
template,
transformValue = (v) => v,
notFoundMessage = 'Ich konnte den Wert leider nicht auslesen.',
}) {
return {
canHandle(handlerInput) {
return (
Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' &&
Alexa.getIntentName(handlerInput.requestEnvelope) === intentName
);
},
async handle(handlerInput) {
try {
const state = await getHaState(entityId);
const rawValue = state.state;
if (UNKNOWN_VALUES.includes(rawValue) || rawValue === undefined || rawValue === null) {
return handlerInput.responseBuilder.speak(notFoundMessage).getResponse();
}
const value = transformValue(rawValue);
const unit = state.attributes?.unit_of_measurement || '';
const speakOutput = template(value, unit, state);
return handlerInput.responseBuilder.speak(speakOutput).getResponse();
} catch (err) {
console.error(`HA API Fehler (${intentName}):`, err);
return handlerInput.responseBuilder
.speak('Ich konnte gerade keine Verbindung zu Home Assistant herstellen. Bitte versuch es später noch einmal.')
.getResponse();
}
},
};
}
/**
* Baut einen Intent-Handler für Sensor-Werte, deren Entity-ID von einem
* gesprochenen Slot abhängt (z.B. Stromkosten nach Tag/Woche/Monat).
* Mehrere solcher Intents können sich denselben Slot Type in der Alexa
* Console teilen (z.B. "PeriodList" für alle Tag/Woche/Monat-Abfragen).
*
* @param {object} config
* @param {string} config.intentName - Intent-Name aus der Alexa Console
* @param {string} config.slotName - Name des Slots im Intent, z.B. "Period"
* @param {object} config.idToEntityMap - Slot-ID -> Entity-ID, z.B. { tag: 'sensor...', woche: '...', monat: '...' }
* @param {function} config.template - (value, unit, slotNameSpoken, slotId, state) => string
* `slotId` ist die feste ID (z.B. "woche"), unabhängig vom genauen Wortlaut -
* nützlich für korrekte Grammatik (z.B. "diesen Tag" vs. "diese Woche").
* @param {function} [config.transformValue] - (rawValue: string) => string|number
* @param {string} [config.unknownSlotMessage] - Antwort, wenn der Slot nicht erkannt wurde.
* @param {function} [config.noMappingMessage] - (slotNameSpoken) => string, wenn die ID nicht im idToEntityMap steht.
* @param {function} [config.notFoundMessage] - (slotNameSpoken) => string, wenn der Sensorwert unknown/unavailable ist.
*/
function makeSlotSensorIntent({
intentName,
slotName,
idToEntityMap,
template,
transformValue = (v) => v,
unknownSlotMessage = 'Das habe ich leider nicht verstanden.',
noMappingMessage = (spoken) => `Für ${spoken} habe ich leider keine Auswertung.`,
notFoundMessage = (spoken) => `Ich konnte den Wert für ${spoken} nicht auslesen.`,
}) {
return {
canHandle(handlerInput) {
return (
Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' &&
Alexa.getIntentName(handlerInput.requestEnvelope) === intentName
);
},
async handle(handlerInput) {
const slots = handlerInput.requestEnvelope.request.intent.slots;
const { id, name: spokenName } = resolveSlot(slots[slotName], 'diesem Wert');
if (!id) {
return handlerInput.responseBuilder
.speak(unknownSlotMessage)
.reprompt(unknownSlotMessage)
.getResponse();
}
const entityId = idToEntityMap[id];
if (!entityId) {
return handlerInput.responseBuilder.speak(noMappingMessage(spokenName)).getResponse();
}
try {
const state = await getHaState(entityId);
const rawValue = state.state;
if (UNKNOWN_VALUES.includes(rawValue) || rawValue === undefined || rawValue === null) {
return handlerInput.responseBuilder.speak(notFoundMessage(spokenName)).getResponse();
}
const value = transformValue(rawValue);
const unit = state.attributes?.unit_of_measurement || '';
const speakOutput = template(value, unit, spokenName, id, state);
return handlerInput.responseBuilder.speak(speakOutput).getResponse();
} catch (err) {
console.error(`HA API Fehler (${intentName}):`, err);
return handlerInput.responseBuilder
.speak('Ich konnte gerade keine Verbindung zu Home Assistant herstellen. Bitte versuch es später noch einmal.')
.getResponse();
}
},
};
}
// Grammatikalisch korrekte Formulierungen je Zeitraum-ID (Genus beachten:
// "der Tag" -> diesen, "die Woche" -> diese, "der Monat" -> diesen).
// In Templates verwenden statt periodSpoken direkt zu nehmen, sonst entsteht
// z.B. "diesen Woche" (falsch) statt "diese Woche" (richtig).
const PERIOD_PHRASES = {
tag: 'diesen Tag',
woche: 'diese Woche',
monat: 'diesen Monat', }­odule.exports = { makeApplianceIntent, makeSimpleSensorIntent, makeSlotSensorIntent, resolveSlot, PERIOD_PHRASES };