// ---- 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', }; module.exports = { makeApplianceIntent, makeSimpleSensorIntent, makeSlotSensorIntent, resolveSlot, PERIOD_PHRASES };