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 // 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 }; // Aktueller Stromverbrauch (Leistung in Watt) const POWER_ENTITY = 'sensor.kammer_netzbezug_plus_keller_power_calc'; // 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', }; // 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'; // 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'; // 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'; }, 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 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; // 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'; 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(); }, }; exports.handler = Alexa.SkillBuilders.custom() .addRequestHandlers( LaunchRequestHandler, GetTemperatureIntentHandler, GetPowerUsageIntentHandler, GetEnergyCostIntentHandler, GetWashingMachineIntentHandler, GetDryerIntentHandler, HelpIntentHandler, CancelAndStopIntentHandler, FallbackIntentHandler, SessionEndedRequestHandler ) .addErrorHandlers(ErrorHandler) .lambda();