factories.js: makeApplianceIntent, makeSimpleSensorIntent, makeSlotSensorIntent + PERIOD_PHRASES
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
// ---- 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 };
|
||||
Reference in New Issue
Block a user