smart-scenarios.tool.ts•21.1 kB
/**
* Smart Scenarios Tool for Home Assistant
*
* Detects and manages common smart home scenarios:
* - Nobody home detection (turn off lights, reduce climate)
* - Window open with heating detection
* - Motion-based lighting
* - Energy saving modes
* - Night mode scenarios
* - Weather-based climate control
*/
import { z } from "zod";
import { UserError } from "fastmcp";
import { logger } from "../../utils/logger.js";
import { BaseTool } from "../base-tool.js";
import { MCPContext } from "../../mcp/types.js";
import { get_hass, call_service } from "../../hass/index.js";
import { Tool } from "../../types/index.js";
// Define the schema for smart scenarios
const smartScenariosSchema = z.object({
action: z
.enum([
"detect_scenarios",
"apply_nobody_home",
"apply_window_heating_check",
"apply_motion_lighting",
"apply_energy_saving",
"apply_night_mode",
"apply_arrival_home",
"detect_issues",
"create_automation",
])
.describe("The smart scenario action to perform"),
mode: z
.enum(["detect", "apply", "auto"])
.optional()
.default("detect")
.describe("detect=only report, apply=execute actions, auto=create automation"),
rooms: z.array(z.string()).optional().describe("Specific rooms/areas to apply scenario to"),
temperature_reduction: z
.number()
.min(1)
.max(10)
.optional()
.default(3)
.describe("Temperature reduction in degrees for climate control (default: 3)"),
enable_notifications: z
.boolean()
.optional()
.default(true)
.describe("Send notifications about scenario detection/actions"),
});
type SmartScenariosParams = z.infer<typeof smartScenariosSchema>;
interface ScenarioDetection {
scenario_type: string;
detected: boolean;
entities_affected: string[];
current_state: string;
recommended_action: string;
automation_config?: Record<string, unknown>;
}
interface ScenarioAction {
action: string;
entity_id: string;
reason?: string;
from?: number;
to?: number;
}
// Smart Scenarios service class
class SmartScenariosService {
/**
* Detect if nobody is home
*/
async detectNobodyHome(): Promise<ScenarioDetection> {
try {
const hass = await get_hass();
const states = await hass.getStates();
// Find person entities and device trackers
const personEntities = states.filter((s) => s.entity_id.startsWith("person."));
const deviceTrackers = states.filter((s) => s.entity_id.startsWith("device_tracker."));
const presenceSensors = states.filter(
(s) => s.entity_id.includes("presence") || s.entity_id.includes("occupancy"),
);
// Check if anyone is home
const anyoneHome =
[
...personEntities.filter((p) => p.state === "home"),
...deviceTrackers.filter((d) => d.state === "home"),
...presenceSensors.filter((p) => p.state === "on" || p.state === "detected"),
].length > 0;
const nobodyHome = !anyoneHome;
// Find lights that are currently on
const lightsOn = states.filter((s) => s.entity_id.startsWith("light.") && s.state === "on");
// Find climate devices that are heating/cooling
const climateActive = states.filter(
(s) =>
s.entity_id.startsWith("climate.") &&
(s.state === "heat" || s.state === "cool" || s.state === "heat_cool"),
);
return {
scenario_type: "nobody_home",
detected: nobodyHome,
entities_affected: [
...lightsOn.map((l) => l.entity_id),
...climateActive.map((c) => c.entity_id),
],
current_state: nobodyHome
? `Nobody home detected. ${lightsOn.length} lights on, ${climateActive.length} climate devices active.`
: "Someone is home.",
recommended_action: nobodyHome
? "Turn off all lights and reduce climate to eco mode"
: "No action needed",
automation_config: nobodyHome
? this.generateNobodyHomeAutomation(lightsOn, climateActive)
: undefined,
};
} catch (error) {
logger.error("Failed to detect nobody home scenario:", error);
throw error;
}
}
/**
* Detect window open with heating
*/
async detectWindowHeatingConflict(): Promise<ScenarioDetection[]> {
try {
const hass = await get_hass();
const states = await hass.getStates();
// Find window/door sensors
const windowSensors = states.filter(
(s) =>
(s.entity_id.includes("window") || s.entity_id.includes("door")) &&
(s.attributes.device_class === "window" ||
s.attributes.device_class === "door" ||
s.attributes.device_class === "opening") &&
(s.state === "on" || s.state === "open"),
);
// Find climate devices that are heating
const climateHeating = states.filter(
(s) =>
s.entity_id.startsWith("climate.") &&
(s.state === "heat" || s.state === "auto") &&
s.attributes.current_temperature < s.attributes.temperature,
);
const conflicts: ScenarioDetection[] = [];
// Check for conflicts by room/area
for (const climate of climateHeating) {
const climateArea =
((climate.attributes.area_id as string | undefined) ?? "") ||
this.extractArea(climate.entity_id);
// Find windows in the same area
const windowsInArea = windowSensors.filter((w) => {
const windowArea =
((w.attributes.area_id as string | undefined) ?? "") || this.extractArea(w.entity_id);
return windowArea === climateArea;
});
if (windowsInArea.length > 0) {
conflicts.push({
scenario_type: "window_heating_conflict",
detected: true,
entities_affected: [climate.entity_id, ...windowsInArea.map((w) => w.entity_id)],
current_state: `Window open in ${climateArea} while heating is active (target: ${climate.attributes.temperature}°C, current: ${climate.attributes.current_temperature}°C)`,
recommended_action: `Turn off climate.${climate.entity_id.split(".")[1]} while window is open`,
automation_config: this.generateWindowHeatingAutomation(climate, windowsInArea),
});
}
}
return conflicts;
} catch (error) {
logger.error("Failed to detect window heating conflicts:", error);
throw error;
}
}
/**
* Detect energy saving opportunities
*/
async detectEnergySavingOpportunities(): Promise<ScenarioDetection[]> {
try {
const hass = await get_hass();
const states = await hass.getStates();
const opportunities: ScenarioDetection[] = [];
// Find lights on during daytime
const currentHour = new Date().getHours();
const isDaytime = currentHour >= 8 && currentHour <= 18;
if (isDaytime) {
const lightsOn = states.filter((s) => s.entity_id.startsWith("light.") && s.state === "on");
if (lightsOn.length > 0) {
opportunities.push({
scenario_type: "daytime_lights",
detected: true,
entities_affected: lightsOn.map((l) => l.entity_id),
current_state: `${lightsOn.length} lights on during daytime`,
recommended_action: "Consider using daylight sensors or turning off unnecessary lights",
automation_config: this.generateDaylightAutomation(lightsOn),
});
}
}
// Find devices in standby consuming power
const powerSensors = states.filter(
(s) => s.attributes.device_class === "power" && s.attributes.unit_of_measurement === "W",
);
const standbyDevices = powerSensors.filter((s) => {
const power = parseFloat(s.state);
return !isNaN(power) && power > 0 && power < 10; // Standby typically 0-10W
});
if (standbyDevices.length > 0) {
opportunities.push({
scenario_type: "standby_power",
detected: true,
entities_affected: standbyDevices.map((d) => d.entity_id),
current_state: `${standbyDevices.length} devices consuming standby power`,
recommended_action: "Use smart plugs to completely turn off devices when not in use",
});
}
// Find climate devices set too high/low
const climateDevices = states.filter((s) => s.entity_id.startsWith("climate."));
for (const climate of climateDevices) {
const targetTemp = climate.attributes.temperature as number | undefined;
const currentTemp = climate.attributes.current_temperature as number | undefined;
if (targetTemp !== undefined && currentTemp !== undefined) {
const isHeating = climate.state === "heat" && targetTemp > 23;
const isCooling = climate.state === "cool" && targetTemp < 20;
if (isHeating || isCooling) {
opportunities.push({
scenario_type: "inefficient_climate",
detected: true,
entities_affected: [climate.entity_id],
current_state: `${climate.entity_id} set to ${targetTemp}°C (${climate.state})`,
recommended_action: isHeating
? "Reduce heating target to 20-22°C to save energy"
: "Increase cooling target to 24-26°C to save energy",
});
}
}
}
return opportunities;
} catch (error) {
logger.error("Failed to detect energy saving opportunities:", error);
throw error;
}
}
/**
* Apply nobody home scenario
*/
async applyNobodyHomeScenario(params: SmartScenariosParams): Promise<{
success: boolean;
actions_taken: ScenarioAction[];
message: string;
}> {
try {
const hass = await get_hass();
const states = await hass.getStates();
const actions: ScenarioAction[] = [];
// Turn off all lights
const lightsOn = states.filter((s) => s.entity_id.startsWith("light.") && s.state === "on");
for (const light of lightsOn) {
if (!params.rooms || this.isInRooms(light, params.rooms)) {
await call_service("light", "turn_off", { entity_id: light.entity_id });
actions.push({ action: "turned_off", entity_id: light.entity_id });
}
}
// Set climate to eco mode or reduce temperature
const climateDevices = states.filter((s) => s.entity_id.startsWith("climate."));
for (const climate of climateDevices) {
if (!params.rooms || this.isInRooms(climate, params.rooms)) {
const currentTemp = climate.attributes.temperature as number | undefined;
// Check if eco_mode preset is available
const presets = (climate.attributes.preset_modes as string[] | undefined) || [];
if (Array.isArray(presets) && presets.includes("eco")) {
await call_service("climate", "set_preset_mode", {
entity_id: climate.entity_id,
preset_mode: "eco",
});
actions.push({ action: "set_eco_mode", entity_id: climate.entity_id });
} else if (currentTemp !== undefined) {
// Reduce temperature
const newTemp = currentTemp - (params.temperature_reduction || 3);
await call_service("climate", "set_temperature", {
entity_id: climate.entity_id,
temperature: newTemp,
});
actions.push({
action: "reduced_temperature",
entity_id: climate.entity_id,
from: currentTemp,
to: newTemp,
});
}
}
}
// Send notification if enabled
if (params.enable_notifications) {
await call_service("notify", "notify", {
message: `Nobody home mode activated. Turned off ${lightsOn.length} lights and adjusted ${climateDevices.length} climate devices.`,
title: "🏠 Smart Home: Nobody Home",
});
}
return {
success: true,
actions_taken: actions,
message: `Applied nobody home scenario. Affected ${actions.length} entities.`,
};
} catch (error) {
logger.error("Failed to apply nobody home scenario:", error);
throw error;
}
}
/**
* Apply window heating check
*/
async applyWindowHeatingCheck(params: SmartScenariosParams): Promise<{
conflicts_found: number;
conflicts: ScenarioDetection[];
actions_taken: ScenarioAction[];
mode: string;
}> {
try {
const conflicts = await this.detectWindowHeatingConflict();
const actions: ScenarioAction[] = [];
if (params.mode === "apply") {
for (const conflict of conflicts) {
// Turn off climate devices
const climateEntity = conflict.entities_affected.find((e) => e.startsWith("climate."));
if (climateEntity !== undefined && climateEntity.length > 0) {
await call_service("climate", "turn_off", { entity_id: climateEntity });
actions.push({
action: "turned_off_climate",
entity_id: climateEntity,
reason: "window_open",
});
}
}
if (params.enable_notifications && conflicts.length > 0) {
await call_service("notify", "notify", {
message: `Turned off ${actions.length} climate devices due to open windows.`,
title: "🪟 Smart Home: Window/Heating Conflict",
});
}
}
return {
conflicts_found: conflicts.length,
conflicts: conflicts,
actions_taken: actions,
mode: params.mode ?? "detect",
};
} catch (error) {
logger.error("Failed to apply window heating check:", error);
throw error;
}
}
// Helper methods
private extractArea(entityId: string): string {
// Try to extract area from entity_id naming convention
// e.g., climate.living_room -> living_room
const parts = entityId.split(".");
if (parts.length > 1) {
return parts[1].replace(/_/g, " ");
}
return "unknown";
}
private isInRooms(
entity: { entity_id: string; attributes: Record<string, unknown> },
rooms: string[],
): boolean {
const area =
((entity.attributes.area_id as string | undefined) ?? "") ||
this.extractArea(entity.entity_id);
return rooms.some((room) => area.toLowerCase().includes(room.toLowerCase()));
}
// Automation generation helpers
private generateNobodyHomeAutomation(
lights: Array<{ entity_id: string }>,
climate: Array<{ entity_id: string }>,
): Record<string, unknown> {
return {
alias: "Nobody Home - Auto Actions",
description: "Automatically turn off lights and reduce climate when nobody is home",
trigger: [
{
platform: "state",
entity_id: "zone.home",
to: "0",
for: { minutes: 5 },
},
],
condition: [],
action: [
{
service: "light.turn_off",
target: {
entity_id: lights.map((l) => l.entity_id),
},
},
{
service: "climate.set_preset_mode",
target: {
entity_id: climate.map((c) => c.entity_id),
},
data: {
preset_mode: "eco",
},
},
{
service: "notify.notify",
data: {
message: "Nobody home mode activated",
title: "Smart Home",
},
},
],
mode: "single",
};
}
private generateWindowHeatingAutomation(
climate: { entity_id: string },
windows: Array<{ entity_id: string }>,
): Record<string, unknown> {
return {
alias: `Window Open Climate Control - ${this.extractArea(climate.entity_id)}`,
description: "Turn off heating when window opens, restore when closed",
trigger: windows.map((w) => ({
platform: "state",
entity_id: w.entity_id,
to: "on",
})),
condition: [
{
condition: "state",
entity_id: climate.entity_id,
state: ["heat", "auto"],
},
],
action: [
{
service: "climate.turn_off",
target: {
entity_id: climate.entity_id,
},
},
{
wait_for_trigger: windows.map((w) => ({
platform: "state",
entity_id: w.entity_id,
to: "off",
for: { minutes: 2 },
})),
timeout: { hours: 4 },
},
{
service: "climate.turn_on",
target: {
entity_id: climate.entity_id,
},
},
],
mode: "restart",
};
}
private generateDaylightAutomation(
lights: Array<{ entity_id: string }>,
): Record<string, unknown> {
return {
alias: "Daylight Savings - Turn off lights",
description: "Turn off lights during bright daylight hours",
trigger: [
{
platform: "sun",
event: "sunrise",
offset: "+01:00:00",
},
],
condition: [],
action: [
{
service: "light.turn_off",
target: {
entity_id: lights.map((l) => l.entity_id),
},
},
],
mode: "single",
};
}
}
// Singleton instance
const smartScenariosService = new SmartScenariosService();
// Execute smart scenarios logic
async function executeSmartScenariosLogic(params: SmartScenariosParams): Promise<string> {
logger.debug(`Executing smart scenarios action: ${params.action}`);
switch (params.action) {
case "detect_scenarios": {
const nobodyHome = await smartScenariosService.detectNobodyHome();
const windowConflicts = await smartScenariosService.detectWindowHeatingConflict();
const energySaving = await smartScenariosService.detectEnergySavingOpportunities();
return JSON.stringify(
{
action: params.action,
timestamp: new Date().toISOString(),
scenarios: {
nobody_home: nobodyHome,
window_heating_conflicts: windowConflicts,
energy_saving_opportunities: energySaving,
},
summary: {
total_issues: 1 + windowConflicts.length + energySaving.length,
nobody_home_detected: nobodyHome.detected,
window_conflicts: windowConflicts.length,
energy_opportunities: energySaving.length,
},
},
null,
2,
);
}
case "apply_nobody_home": {
const result = await smartScenariosService.applyNobodyHomeScenario(params);
return JSON.stringify(result, null, 2);
}
case "apply_window_heating_check": {
const result = await smartScenariosService.applyWindowHeatingCheck(params);
return JSON.stringify(result, null, 2);
}
case "detect_issues": {
const windowConflicts = await smartScenariosService.detectWindowHeatingConflict();
const energySaving = await smartScenariosService.detectEnergySavingOpportunities();
return JSON.stringify(
{
action: params.action,
timestamp: new Date().toISOString(),
issues: [...windowConflicts, ...energySaving],
total_issues: windowConflicts.length + energySaving.length,
},
null,
2,
);
}
case "create_automation": {
throw new UserError(
"Automatic automation creation is not yet implemented. " +
"Use the 'detect_scenarios' action to get automation configs, " +
"then create them manually or use the automation_config tool.",
);
}
default:
throw new UserError(
`Action ${params.action} is not yet implemented. Available: detect_scenarios, apply_nobody_home, apply_window_heating_check, detect_issues`,
);
}
}
// Export the tool
export const smartScenariosTool: Tool = {
name: "smart_scenarios",
description:
"Detect and manage smart home scenarios: nobody home, window/heating conflicts, energy saving, and more",
parameters: smartScenariosSchema,
execute: executeSmartScenariosLogic,
};
// Export class for compatibility
export class SmartScenariosTool extends BaseTool {
constructor() {
super({
name: smartScenariosTool.name,
description: smartScenariosTool.description,
parameters: smartScenariosSchema,
metadata: {
category: "automation",
version: "1.0.0",
tags: ["smart_home", "scenarios", "automation", "energy"],
},
});
}
public async execute(params: SmartScenariosParams, context: MCPContext): Promise<string> {
logger.debug(`Executing SmartScenariosTool with params: ${JSON.stringify(params)}`);
try {
const validatedParams = this.validateParams(params);
return await executeSmartScenariosLogic(validatedParams);
} catch (error) {
logger.error(`Error in SmartScenariosTool: ${String(error)}`);
throw error;
}
}
}