From 30ae80262854e6f74473024fd8465cb0f29d4257 Mon Sep 17 00:00:00 2001 From: Oliver Vogt Date: Mon, 24 Aug 2026 19:52:09 +0200 Subject: [PATCH 1/6] ha-client.js: HA-API-Client als eigenes Modul ausgelagert --- home-assistant-skill/ha-client.js | 48 +++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 home-assistant-skill/ha-client.js diff --git a/home-assistant-skill/ha-client.js b/home-assistant-skill/ha-client.js new file mode 100644 index 0000000..65a3023 --- /dev/null +++ b/home-assistant-skill/ha-client.js @@ -0,0 +1,48 @@ +// ---- Home Assistant API Client ---- +require('dotenv').config(); + +const https = require('https'); + +const HA_URL = process.env.HA_URL; // z.B. "https://haos.vogt.de.com" +const HA_TOKEN = process.env.HA_TOKEN; // Long-Lived Access Token aus HA + +/** + * Fragt den aktuellen Zustand einer Home-Assistant-Entity ab. + * @param {string} entityId - z.B. "sensor.wohnzimmer_temperatur" + * @returns {Promise<{state: string, attributes: object}>} + */ +function getHaState(entityId) { + return new Promise((resolve, reject) => { + const url = new URL(`${HA_URL}/api/states/${entityId}`); + const options = { + hostname: url.hostname, + path: url.pathname, + method: 'GET', + headers: { + Authorization: `Bearer ${HA_TOKEN}`, + 'Content-Type': 'application/json', + }, + }; + + const req = https.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`HA API Fehler: ${res.statusCode} - ${data}`)); + return; + } + try { + resolve(JSON.parse(data)); + } catch (e) { + reject(e); + } + }); + }); + + req.on('error', reject); + req.end(); + }); +} + +module.exports = { getHaState }; From 6446712f5097a4174cbeee86264f3c5fb4ce84df Mon Sep 17 00:00:00 2001 From: Oliver Vogt Date: Mon, 24 Aug 2026 19:53:01 +0200 Subject: [PATCH 2/6] static-handlers.js: Pflicht-Handler (Launch/Help/Cancel/Fallback/Error) ausgelagert --- home-assistant-skill/static-handlers.js | 84 +++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 home-assistant-skill/static-handlers.js diff --git a/home-assistant-skill/static-handlers.js b/home-assistant-skill/static-handlers.js new file mode 100644 index 0000000..d7ec131 --- /dev/null +++ b/home-assistant-skill/static-handlers.js @@ -0,0 +1,84 @@ +// ---- Standard-Handler (Pflicht für jeden Alexa Skill) ---- +const Alexa = require('ask-sdk-core'); + +const LaunchRequestHandler = { + canHandle(handlerInput) { + return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest'; + }, + handle(handlerInput) { + const speakOutput = 'Willkommen beim Hausassistent. Du kannst mich zum Beispiel fragen, wie warm es in einem Raum ist.'; + return handlerInput.responseBuilder + .speak(speakOutput) + .reprompt(speakOutput) + .getResponse(); + }, +}; + +const HelpIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.HelpIntent' + ); + }, + handle(handlerInput) { + const speakOutput = 'Du kannst mich zum Beispiel fragen: wie warm ist es im Wohnzimmer.'; + return handlerInput.responseBuilder.speak(speakOutput).reprompt(speakOutput).getResponse(); + }, +}; + +const CancelAndStopIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + (Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.CancelIntent' || + Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.StopIntent') + ); + }, + handle(handlerInput) { + const speakOutput = 'Bis bald!'; + return handlerInput.responseBuilder.speak(speakOutput).getResponse(); + }, +}; + +const FallbackIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.FallbackIntent' + ); + }, + handle(handlerInput) { + const speakOutput = 'Das habe ich nicht verstanden. Du kannst mich zum Beispiel fragen, wie warm es im Wohnzimmer ist.'; + return handlerInput.responseBuilder.speak(speakOutput).reprompt(speakOutput).getResponse(); + }, +}; + +const SessionEndedRequestHandler = { + canHandle(handlerInput) { + return Alexa.getRequestType(handlerInput.requestEnvelope) === 'SessionEndedRequest'; + }, + handle(handlerInput) { + return handlerInput.responseBuilder.getResponse(); + }, +}; + +const ErrorHandler = { + canHandle() { + return true; + }, + handle(handlerInput, error) { + console.error(`~~~~ Error handled: ${error.stack}`); + const speakOutput = 'Entschuldigung, da ist etwas schiefgelaufen. Bitte versuch es noch einmal.'; + return handlerInput.responseBuilder.speak(speakOutput).reprompt(speakOutput).getResponse(); + }, +}; + +module.exports = { + LaunchRequestHandler, + HelpIntentHandler, + CancelAndStopIntentHandler, + FallbackIntentHandler, + SessionEndedRequestHandler, + ErrorHandler, +}; From 8b3efe99e5725ed1894527e3994656b4533f6c69 Mon Sep 17 00:00:00 2001 From: Oliver Vogt Date: Mon, 24 Aug 2026 19:57:08 +0200 Subject: [PATCH 3/6] 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 }; From a0f6b0a8eb50b0b251c591b0bdd4004202914227 Mon Sep 17 00:00:00 2001 From: Oliver Vogt Date: Mon, 24 Aug 2026 20:06:23 +0200 Subject: [PATCH 4/6] factories.js: Dateiende-Korruption beheben (fehlendes '};' vor module.exports) --- home-assistant-skill/factories.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/home-assistant-skill/factories.js b/home-assistant-skill/factories.js index 3e4d73f..9f3aa30 100644 --- a/home-assistant-skill/factories.js +++ b/home-assistant-skill/factories.js @@ -255,4 +255,7 @@ function makeSlotSensorIntent({ const PERIOD_PHRASES = { tag: 'diesen Tag', woche: 'diese Woche', - monat: 'diesen Monat', }­odule.exports = { makeApplianceIntent, makeSimpleSensorIntent, makeSlotSensorIntent, resolveSlot, PERIOD_PHRASES }; + monat: 'diesen Monat', +}; + +module.exports = { makeApplianceIntent, makeSimpleSensorIntent, makeSlotSensorIntent, resolveSlot, PERIOD_PHRASES }; From 11aea0a6ab2d7f3628faa3bf2c82b33cef1c9ad4 Mon Sep 17 00:00:00 2001 From: Oliver Vogt Date: Mon, 24 Aug 2026 20:17:47 +0200 Subject: [PATCH 5/6] index.js: schlanke Version mit Factories, PERIOD_PHRASES, GetPowerKammerUsageIntent, GetEnergyCostKammerIntent --- home-assistant-skill/index.js | 462 +++++----------------------------- 1 file changed, 69 insertions(+), 393 deletions(-) diff --git a/home-assistant-skill/index.js b/home-assistant-skill/index.js index ff3cc1b..14702e7 100644 --- a/home-assistant-skill/index.js +++ b/home-assistant-skill/index.js @@ -1,415 +1,91 @@ const Alexa = require('ask-sdk-core'); -const https = require('https'); -// ---- Konfiguration ---- -// Werte kommen aus der .env-Datei im selben Verzeichnis (siehe unten) -require('dotenv').config(); -const HA_URL = process.env.HA_URL; // z.B. "https://haos.vogt.de.com" -const HA_TOKEN = process.env.HA_TOKEN; // Long-Lived Access Token aus HA +const { makeApplianceIntent, makeSimpleSensorIntent, makeSlotSensorIntent, PERIOD_PHRASES } = require('./factories'); +const { + LaunchRequestHandler, + HelpIntentHandler, + CancelAndStopIntentHandler, + FallbackIntentHandler, + SessionEndedRequestHandler, + ErrorHandler, +} = require('./static-handlers'); -// Raum-ID (aus dem RoomList Slot) -> climate Entity-ID in Home Assistant -const roomToClimateEntity = { - badezimmer: 'climate.badezimmer_heizungssteuerung', - kinderzimmer: 'climate.kinderzimmer_heizungssteuerung', - kueche: 'climate.kueche_heizungssteuerung', - schlafzimmer: 'climate.schlafzimmer_heizungssteuerung', - wohnzimmer: 'climate.wohnzimmer_heizungssteuerung', - // diele, gaeste_wc, balkon_hinten, balkon_vorne, kammer: kein Sensor vorhanden -}; +// Hinweis: Temperaturabfragen laufen jetzt ueber die native Nabu-Casa- +// Smart-Home-Integration, nicht mehr ueber diesen Custom Skill. -// Aktueller Stromverbrauch (Leistung in Watt) -const POWER_ENTITY = 'sensor.kammer_netzbezug_plus_keller_power_calc'; +// ---- Einfache Sensor-Werte (kein Slot) ---- -// Stromkosten je Zeitraum (Periode-ID aus dem PeriodList Slot -> Entity-ID) -const periodToEnergyCostEntity = { - tag: 'sensor.netzbezug_kosten_tag', - woche: 'sensor.netzbezug_kosten_woche', - monat: 'sensor.netzbezug_kosten_monat', -}; +const GetPowerUsageIntentHandler = makeSimpleSensorIntent({ + intentName: 'GetPowerUsageIntent', + entityId: 'sensor.kammer_netzbezug_plus_keller_power_calc', + transformValue: (v) => Math.round(parseFloat(v)), + template: (value, unit) => `Der aktuelle Stromverbrauch betraegt ${value} ${unit || 'Watt'}.`, + notFoundMessage: 'Ich konnte den aktuellen Stromverbrauch nicht auslesen.', +}); -// Waschmaschine -const WASHING_MACHINE_STATUS_ENTITY = 'sensor.waschmaschine_betriebszustand'; -const WASHING_MACHINE_PROGRESS_ENTITY = 'sensor.waschmaschine_programm_fortschritt'; -const WASHING_MACHINE_END_TIME_ENTITY = 'sensor.waschmaschine_programm_endzeit'; +const GetPowerKammerUsageIntentHandler = makeSimpleSensorIntent({ + intentName: 'GetPowerKammerUsageIntent', + entityId: 'sensor.kammer_serverschrank_leistung', + transformValue: (v) => Math.round(parseFloat(v)), + template: (value, unit) => `Der aktuelle Stromverbrauch in der Kammer betraegt ${value} ${unit || 'Watt'}.`, + notFoundMessage: 'Ich konnte den aktuellen Stromverbrauch in der Kammer nicht auslesen.', +}); -// Trockner -const DRYER_STATUS_ENTITY = 'sensor.trockner_betriebszustand'; -const DRYER_PROGRESS_ENTITY = 'sensor.trockner_programm_fortschritt'; -const DRYER_END_TIME_ENTITY = 'sensor.trockner_programm_endzeit'; +// ---- Stromkosten nach Zeitraum (Slot Period) ---- -// Hilfsfunktion: HA REST API abfragen -function getHaState(entityId) { - return new Promise((resolve, reject) => { - const url = new URL(`${HA_URL}/api/states/${entityId}`); - const options = { - hostname: url.hostname, - path: url.pathname, - method: 'GET', - headers: { - Authorization: `Bearer ${HA_TOKEN}`, - 'Content-Type': 'application/json', - }, - }; - - const req = https.request(options, (res) => { - let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => { - if (res.statusCode !== 200) { - reject(new Error(`HA API Fehler: ${res.statusCode} - ${data}`)); - return; - } - try { - resolve(JSON.parse(data)); - } catch (e) { - reject(e); - } - }); - }); - - req.on('error', reject); - req.end(); - }); -} - -const LaunchRequestHandler = { - canHandle(handlerInput) { - return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest'; +const GetEnergyCostIntentHandler = makeSlotSensorIntent({ + intentName: 'GetEnergyCostIntent', + slotName: 'Period', + idToEntityMap: { + tag: 'sensor.netzbezug_kosten_tag', + woche: 'sensor.netzbezug_kosten_woche', + monat: 'sensor.netzbezug_kosten_monat', }, - handle(handlerInput) { - const speakOutput = 'Willkommen beim Hausassistent. Du kannst mich zum Beispiel fragen, wie warm es in einem Raum ist.'; - return handlerInput.responseBuilder - .speak(speakOutput) - .reprompt(speakOutput) - .getResponse(); + transformValue: (v) => parseFloat(v).toFixed(2).replace('.', ','), + template: (value, unit, periodSpoken, periodId) => + `Die Stromkosten fuer ${PERIOD_PHRASES[periodId] || periodSpoken} betragen ${value} ${unit || 'Euro'}.`, + unknownSlotMessage: 'Ich habe den Zeitraum leider nicht verstanden. Du kannst zum Beispiel nach Tag, Woche oder Monat fragen.', +}); + +const GetEnergyCostKammerIntentHandler = makeSlotSensorIntent({ + intentName: 'GetEnergyCostKammerIntent', + slotName: 'Period', + idToEntityMap: { + tag: 'sensor.kammer_serverschrank_kosten_tag', + woche: 'sensor.kammer_serverschrank_kosten_woche', + monat: 'sensor.kammer_serverschrank_kosten_monat', }, -}; + transformValue: (v) => parseFloat(v).toFixed(2).replace('.', ','), + template: (value, unit, periodSpoken, periodId) => + `Die Stromkosten fuer die Kammer ${PERIOD_PHRASES[periodId] || periodSpoken} betragen ${value} ${unit || 'Euro'}.`, + unknownSlotMessage: 'Ich habe den Zeitraum leider nicht verstanden. Du kannst zum Beispiel nach Tag, Woche oder Monat fragen.', +}); -const GetTemperatureIntentHandler = { - canHandle(handlerInput) { - return ( - Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && - Alexa.getIntentName(handlerInput.requestEnvelope) === 'GetTemperatureIntent' - ); - }, - async handle(handlerInput) { - const slots = handlerInput.requestEnvelope.request.intent.slots; - const roomSlot = slots.Room; +// ---- Geraete mit Status/Fortschritt/Endzeit ---- - // Die ID und den sauberen Namen aus dem Slot Type RoomList holen - // (nicht den roh gesprochenen Text, der z.B. "wohnzimmer ist" statt "wohnzimmer" sein kann) - const resolvedValue = roomSlot?.resolutions?.resolutionsPerAuthority?.[0]?.values?.[0]?.value; - const roomId = resolvedValue?.id; - const roomNameSpoken = resolvedValue?.name || roomSlot?.value || 'diesem Raum'; +const GetWashingMachineIntentHandler = makeApplianceIntent({ + intentName: 'GetWashingMachineIntent', + deviceName: 'Die Waschmaschine', + statusEntity: 'sensor.waschmaschine_betriebszustand', + progressEntity: 'sensor.waschmaschine_programm_fortschritt', + endTimeEntity: 'sensor.waschmaschine_programm_endzeit', +}); - if (!roomId) { - return handlerInput.responseBuilder - .speak(`Ich kenne den Raum ${roomNameSpoken} leider nicht. Bitte versuch es mit einem anderen Raumnamen.`) - .reprompt('Für welchen Raum möchtest du die Temperatur wissen?') - .getResponse(); - } - - const entityId = roomToClimateEntity[roomId]; - - if (!entityId) { - return handlerInput.responseBuilder - .speak(`Für ${roomNameSpoken} habe ich leider keinen Temperatursensor.`) - .getResponse(); - } - - try { - const state = await getHaState(entityId); - const currentTemp = state.attributes?.current_temperature; - - if (currentTemp === undefined || currentTemp === null) { - return handlerInput.responseBuilder - .speak(`Ich konnte die aktuelle Temperatur für ${roomNameSpoken} nicht auslesen.`) - .getResponse(); - } - - const speakOutput = `Die Temperatur im ${roomNameSpoken} beträgt ${currentTemp} Grad.`; - return handlerInput.responseBuilder.speak(speakOutput).getResponse(); - } catch (err) { - console.error('HA API Fehler:', err); - return handlerInput.responseBuilder - .speak('Ich konnte gerade keine Verbindung zu Home Assistant herstellen. Bitte versuch es später noch einmal.') - .getResponse(); - } - }, -}; - -const GetPowerUsageIntentHandler = { - canHandle(handlerInput) { - return ( - Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && - Alexa.getIntentName(handlerInput.requestEnvelope) === 'GetPowerUsageIntent' - ); - }, - async handle(handlerInput) { - try { - const state = await getHaState(POWER_ENTITY); - const powerValue = state.state; - - if (powerValue === undefined || powerValue === null || powerValue === 'unknown' || powerValue === 'unavailable') { - return handlerInput.responseBuilder - .speak('Ich konnte den aktuellen Stromverbrauch nicht auslesen.') - .getResponse(); - } - - const unit = state.attributes?.unit_of_measurement || 'Watt'; - const roundedPower = Math.round(parseFloat(powerValue)); - const speakOutput = `Der aktuelle Stromverbrauch beträgt ${roundedPower} ${unit}.`; - return handlerInput.responseBuilder.speak(speakOutput).getResponse(); - } catch (err) { - console.error('HA API Fehler:', err); - return handlerInput.responseBuilder - .speak('Ich konnte gerade keine Verbindung zu Home Assistant herstellen. Bitte versuch es später noch einmal.') - .getResponse(); - } - }, -}; - -const GetEnergyCostIntentHandler = { - canHandle(handlerInput) { - return ( - Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && - Alexa.getIntentName(handlerInput.requestEnvelope) === 'GetEnergyCostIntent' - ); - }, - async handle(handlerInput) { - const slots = handlerInput.requestEnvelope.request.intent.slots; - const periodSlot = slots.Period; - - const resolvedValue = periodSlot?.resolutions?.resolutionsPerAuthority?.[0]?.values?.[0]?.value; - const periodId = resolvedValue?.id; - const periodNameSpoken = resolvedValue?.name || periodSlot?.value || 'diesem Zeitraum'; - - if (!periodId) { - return handlerInput.responseBuilder - .speak('Ich habe den Zeitraum leider nicht verstanden. Du kannst zum Beispiel nach Tag, Woche oder Monat fragen.') - .reprompt('Für welchen Zeitraum möchtest du die Stromkosten wissen?') - .getResponse(); - } - - const entityId = periodToEnergyCostEntity[periodId]; - - if (!entityId) { - return handlerInput.responseBuilder - .speak(`Für ${periodNameSpoken} habe ich leider keine Kostenauswertung.`) - .getResponse(); - } - - try { - const state = await getHaState(entityId); - const costValue = state.state; - - if (costValue === undefined || costValue === null || costValue === 'unknown' || costValue === 'unavailable') { - return handlerInput.responseBuilder - .speak(`Ich konnte die Stromkosten für ${periodNameSpoken} nicht auslesen.`) - .getResponse(); - } - - const unit = state.attributes?.unit_of_measurement || 'Euro'; - const roundedCost = parseFloat(costValue).toFixed(2).replace('.', ','); - const speakOutput = `Die Stromkosten für diesen ${periodNameSpoken} betragen ${roundedCost} ${unit}.`; - return handlerInput.responseBuilder.speak(speakOutput).getResponse(); - } catch (err) { - console.error('HA API Fehler:', err); - return handlerInput.responseBuilder - .speak('Ich konnte gerade keine Verbindung zu Home Assistant herstellen. Bitte versuch es später noch einmal.') - .getResponse(); - } - }, -}; - -const GetWashingMachineIntentHandler = { - canHandle(handlerInput) { - return ( - Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && - Alexa.getIntentName(handlerInput.requestEnvelope) === 'GetWashingMachineIntent' - ); - }, - async handle(handlerInput) { - try { - const [statusState, progressState, endTimeState] = await Promise.all([ - getHaState(WASHING_MACHINE_STATUS_ENTITY).catch(() => null), // optional, falls Entity nicht existiert - getHaState(WASHING_MACHINE_PROGRESS_ENTITY), - getHaState(WASHING_MACHINE_END_TIME_ENTITY), - ]); - - // Betriebszustand prüfen (z.B. "fertig", "inaktiv") - analog zum HA-Template - const betrieb = statusState?.state?.toLowerCase().trim(); - const inactiveStates = ['fertig', 'inaktiv', 'finished', 'inactive']; - if (betrieb && inactiveStates.includes(betrieb)) { - return handlerInput.responseBuilder - .speak('Die Waschmaschine läuft aktuell nicht.') - .getResponse(); - } - - const progress = progressState.state; - const endTimeRaw = endTimeState.state; - const unknownValues = ['unknown', 'unavailable', 'none']; - - if (unknownValues.includes(progress) || unknownValues.includes(endTimeRaw)) { - return handlerInput.responseBuilder - .speak('Die Waschmaschine läuft aktuell nicht.') - .getResponse(); - } - - const endTime = new Date(endTimeRaw); - const now = new Date(); - const diffMs = endTime.getTime() - now.getTime(); - - if (diffMs <= 0) { - return handlerInput.responseBuilder.speak('Die Waschmaschine ist fertig.').getResponse(); - } - - const roundedProgress = Math.round(parseFloat(progress)); - - // Endzeit als HH:MM in deutscher Zeitzone formatieren - const timeFormatted = new Intl.DateTimeFormat('de-DE', { - hour: '2-digit', - minute: '2-digit', - timeZone: 'Europe/Berlin', - }).format(endTime); - - const speakOutput = `Die Waschmaschine ist ${roundedProgress} Prozent fertig und endet um ${timeFormatted} Uhr.`; - return handlerInput.responseBuilder.speak(speakOutput).getResponse(); - } catch (err) { - console.error('HA API Fehler:', err); - return handlerInput.responseBuilder - .speak('Ich konnte gerade keine Verbindung zu Home Assistant herstellen. Bitte versuch es später noch einmal.') - .getResponse(); - } - }, -}; - -const GetDryerIntentHandler = { - canHandle(handlerInput) { - return ( - Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && - Alexa.getIntentName(handlerInput.requestEnvelope) === 'GetDryerIntent' - ); - }, - async handle(handlerInput) { - try { - const [statusState, progressState, endTimeState] = await Promise.all([ - getHaState(DRYER_STATUS_ENTITY).catch(() => null), - getHaState(DRYER_PROGRESS_ENTITY), - getHaState(DRYER_END_TIME_ENTITY), - ]); - - const betrieb = statusState?.state?.toLowerCase().trim(); - const inactiveStates = ['fertig', 'inaktiv', 'finished', 'inactive']; - if (betrieb && inactiveStates.includes(betrieb)) { - return handlerInput.responseBuilder - .speak('Der Trockner läuft aktuell nicht.') - .getResponse(); - } - - const progress = progressState.state; - const endTimeRaw = endTimeState.state; - const unknownValues = ['unknown', 'unavailable', 'none']; - - if (unknownValues.includes(progress) || unknownValues.includes(endTimeRaw)) { - return handlerInput.responseBuilder - .speak('Der Trockner läuft aktuell nicht.') - .getResponse(); - } - - const endTime = new Date(endTimeRaw); - const now = new Date(); - const diffMs = endTime.getTime() - now.getTime(); - - if (diffMs <= 0) { - return handlerInput.responseBuilder.speak('Der Trockner ist fertig.').getResponse(); - } - - const roundedProgress = Math.round(parseFloat(progress)); - - const timeFormatted = new Intl.DateTimeFormat('de-DE', { - hour: '2-digit', - minute: '2-digit', - timeZone: 'Europe/Berlin', - }).format(endTime); - - const speakOutput = `Der Trockner ist ${roundedProgress} Prozent fertig und endet um ${timeFormatted} Uhr.`; - return handlerInput.responseBuilder.speak(speakOutput).getResponse(); - } catch (err) { - console.error('HA API Fehler:', err); - return handlerInput.responseBuilder - .speak('Ich konnte gerade keine Verbindung zu Home Assistant herstellen. Bitte versuch es später noch einmal.') - .getResponse(); - } - }, -}; - -const HelpIntentHandler = { - canHandle(handlerInput) { - return ( - Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && - Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.HelpIntent' - ); - }, - handle(handlerInput) { - const speakOutput = 'Du kannst mich zum Beispiel fragen: wie warm ist es im Wohnzimmer.'; - return handlerInput.responseBuilder.speak(speakOutput).reprompt(speakOutput).getResponse(); - }, -}; - -const CancelAndStopIntentHandler = { - canHandle(handlerInput) { - return ( - Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && - (Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.CancelIntent' || - Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.StopIntent') - ); - }, - handle(handlerInput) { - const speakOutput = 'Bis bald!'; - return handlerInput.responseBuilder.speak(speakOutput).getResponse(); - }, -}; - -const FallbackIntentHandler = { - canHandle(handlerInput) { - return ( - Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && - Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.FallbackIntent' - ); - }, - handle(handlerInput) { - const speakOutput = 'Das habe ich nicht verstanden. Du kannst mich zum Beispiel fragen, wie warm es im Wohnzimmer ist.'; - return handlerInput.responseBuilder.speak(speakOutput).reprompt(speakOutput).getResponse(); - }, -}; - -const SessionEndedRequestHandler = { - canHandle(handlerInput) { - return Alexa.getRequestType(handlerInput.requestEnvelope) === 'SessionEndedRequest'; - }, - handle(handlerInput) { - return handlerInput.responseBuilder.getResponse(); - }, -}; - -const ErrorHandler = { - canHandle() { - return true; - }, - handle(handlerInput, error) { - console.error(`~~~~ Error handled: ${error.stack}`); - const speakOutput = 'Entschuldigung, da ist etwas schiefgelaufen. Bitte versuch es noch einmal.'; - return handlerInput.responseBuilder.speak(speakOutput).reprompt(speakOutput).getResponse(); - }, -}; +const GetDryerIntentHandler = makeApplianceIntent({ + intentName: 'GetDryerIntent', + deviceName: 'Der Trockner', + statusEntity: 'sensor.trockner_betriebszustand', + progressEntity: 'sensor.trockner_programm_fortschritt', + endTimeEntity: 'sensor.trockner_programm_endzeit', +}); exports.handler = Alexa.SkillBuilders.custom() .addRequestHandlers( LaunchRequestHandler, - GetTemperatureIntentHandler, GetPowerUsageIntentHandler, + GetPowerKammerUsageIntentHandler, GetEnergyCostIntentHandler, + GetEnergyCostKammerIntentHandler, GetWashingMachineIntentHandler, GetDryerIntentHandler, HelpIntentHandler, From 1b66117b25f71e3ecc3bffe265b088d300b387a8 Mon Sep 17 00:00:00 2001 From: Oliver Vogt Date: Mon, 24 Aug 2026 20:20:12 +0200 Subject: [PATCH 6/6] ARCHITECTURE.md: neue Multi-File-Struktur und Factory-Muster dokumentiert --- home-assistant-skill/ARCHITECTURE.md | 72 ++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 home-assistant-skill/ARCHITECTURE.md diff --git a/home-assistant-skill/ARCHITECTURE.md b/home-assistant-skill/ARCHITECTURE.md new file mode 100644 index 0000000..1807cc9 --- /dev/null +++ b/home-assistant-skill/ARCHITECTURE.md @@ -0,0 +1,72 @@ + +--- + +## Architektur (Refactoring) + +Der Code ist jetzt auf mehrere Dateien aufgeteilt, damit neue Sensoren/Geraete +nur noch wenige Zeilen Config statt eines kompletten Handlers brauchen: + +``` +home-assistant-skill/ ++-- index.js # Nur noch Config: ruft die Factories mit den echten +| # Entity-IDs auf und registriert die Handler ++-- factories.js # makeApplianceIntent, makeSimpleSensorIntent, +| # makeSlotSensorIntent, PERIOD_PHRASES ++-- ha-client.js # getHaState() - HA REST API Client ++-- static-handlers.js # Launch/Help/Cancel/Fallback/Error (Pflicht-Handler) ++-- package.json ++-- .env.example +``` + +Neuer einfacher Sensor (kein Slot), z.B. Luftfeuchtigkeit: + +```js +const GetHumidityIntentHandler = makeSimpleSensorIntent({ + intentName: 'GetHumidityIntent', + entityId: 'sensor.wohnzimmer_luftfeuchtigkeit', + template: (value, unit) => `Die Luftfeuchtigkeit betraegt ${value} ${unit}.`, +}); +``` + +Neues Geraet mit Status/Fortschritt/Endzeit, z.B. Geschirrspueler ohne +Fortschritt (nur Status, mit individueller Formulierung ueber +`customStatusTemplate`): + +```js +const GetDishwasherIntentHandler = makeApplianceIntent({ + intentName: 'GetDishwasherIntent', + deviceName: 'Der Geschirrspueler', + statusEntity: 'sensor.geschirrspueler_betriebszustand', + customStatusTemplate: (state) => `Der Geschirrspueler laeuft im Programm ${state}.`, +}); +``` + +Neue Kostengruppe mit Zeitraum-Slot (Tag/Woche/Monat), nutzt denselben +`PeriodList` Slot Type wie `GetEnergyCostIntent`: + +```js +const GetXyzEnergyCostIntentHandler = makeSlotSensorIntent({ + intentName: 'GetXyzEnergyCostIntent', + slotName: 'Period', + idToEntityMap: { + tag: 'sensor.xyz_kosten_tag', + woche: 'sensor.xyz_kosten_woche', + monat: 'sensor.xyz_kosten_monat', + }, + transformValue: (v) => parseFloat(v).toFixed(2).replace('.', ','), + template: (value, unit, periodSpoken, periodId) => + `Die Stromkosten fuer ${PERIOD_PHRASES[periodId] || periodSpoken} betragen ${value} ${unit}.`, +}); +``` + +In jedem Fall bleibt bestehen: Der zugehoerige **Intent mit Sample Utterances** +muss weiterhin in der Alexa Developer Console angelegt werden - das laesst +sich unabhaengig vom Code-Aufbau nicht vermeiden. + +`PERIOD_PHRASES` sorgt fuer korrekte deutsche Grammatik ("diese Woche" statt +"diesen Woche"), da `periodId` (die feste Slot-ID) statt des roh gesprochenen +Texts fuer die Formulierung verwendet wird. + +Aktuell zusaetzlich vorhanden: `GetPowerKammerUsageIntent` (Leistung +Kammer-Serverschrank) und `GetEnergyCostKammerIntent` (Stromkosten +Kammer-Serverschrank nach Tag/Woche/Monat) - gleiches Muster wie oben.