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,