From 8b3efe99e5725ed1894527e3994656b4533f6c69 Mon Sep 17 00:00:00 2001 From: Oliver Vogt Date: Mon, 24 Aug 2026 19:57:08 +0200 Subject: [PATCH] factories.js: makeApplianceIntent, makeSimpleSensorIntent, makeSlotSensorIntent + PERIOD_PHRASES --- home-assistant-skill/factories.js | 258 ++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 home-assistant-skill/factories.js diff --git a/home-assistant-skill/factories.js b/home-assistant-skill/factories.js new file mode 100644 index 0000000..3e4d73f --- /dev/null +++ b/home-assistant-skill/factories.js @@ -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 };