Skip to content

Commit 31eb1b0

Browse files
committed
feat: add Home Assistant / IoT connector with simulated dashboard widget and AI control tools
1 parent 88f1667 commit 31eb1b0

6 files changed

Lines changed: 940 additions & 3 deletions

File tree

apps/website/src/app/api/ask/route.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,146 @@ const queryConnectorsTool = (userId: string, workspaceId: string) => tool(
209209
}
210210
);
211211

212+
const getIotDeviceStatesTool = (userId: string, workspaceId: string) => tool(
213+
async () => {
214+
try {
215+
const [account] = await db
216+
.select()
217+
.from(connectorAccounts)
218+
.where(
219+
and(
220+
eq(connectorAccounts.userId, userId),
221+
eq(connectorAccounts.workspaceId, workspaceId),
222+
eq(connectorAccounts.provider, "homeassistant"),
223+
eq(connectorAccounts.status, "connected")
224+
)
225+
)
226+
.limit(1);
227+
228+
if (!account) {
229+
return "The Home Assistant / IoT connector is not connected yet. Please instruct the user to go to Settings > Connectors to connect it (either in real or simulated mode).";
230+
}
231+
232+
const metadata = JSON.parse(account.metadataJson || "{}");
233+
return JSON.stringify(metadata.devices || {});
234+
} catch (err: any) {
235+
return `Error retrieving IoT device states: ${err.message || err}`;
236+
}
237+
},
238+
{
239+
name: "get_iot_device_states",
240+
description: "Get the current states of all connected smart home devices (lights, switches, climate, locks, etc.).",
241+
schema: z.object({}),
242+
}
243+
);
244+
245+
const controlIotDeviceTool = (userId: string, workspaceId: string) => tool(
246+
async (input: any) => {
247+
try {
248+
const { entityId, state, brightness, temperature } = input;
249+
250+
const [account] = await db
251+
.select()
252+
.from(connectorAccounts)
253+
.where(
254+
and(
255+
eq(connectorAccounts.userId, userId),
256+
eq(connectorAccounts.workspaceId, workspaceId),
257+
eq(connectorAccounts.provider, "homeassistant"),
258+
eq(connectorAccounts.status, "connected")
259+
)
260+
)
261+
.limit(1);
262+
263+
if (!account) {
264+
return "The Home Assistant / IoT connector is not connected yet. Please ask the user to connect it.";
265+
}
266+
267+
const metadata = JSON.parse(account.metadataJson || "{}");
268+
const { url, token, simulated, devices = {} } = metadata;
269+
270+
const device = devices[entityId];
271+
if (!device) {
272+
return `Device with entity ID "${entityId}" was not found. Available devices: ${Object.keys(devices).join(", ")}`;
273+
}
274+
275+
const updatedDevice = { ...device };
276+
if (entityId.startsWith("lock.")) {
277+
updatedDevice.state = state === "lock" || state === "locked" ? "locked" : "unlocked";
278+
} else {
279+
updatedDevice.state = state;
280+
}
281+
if (brightness !== undefined) updatedDevice.brightness = brightness;
282+
if (temperature !== undefined) updatedDevice.temperature = temperature;
283+
284+
if (!simulated) {
285+
const [domain] = entityId.split(".");
286+
let service = "";
287+
let body: Record<string, any> = { entity_id: entityId };
288+
289+
if (domain === "light" || domain === "switch") {
290+
service = state === "on" ? "turn_on" : "turn_off";
291+
if (domain === "light" && brightness !== undefined) {
292+
body.brightness = brightness;
293+
}
294+
} else if (domain === "lock") {
295+
service = state === "lock" || state === "locked" ? "lock" : "unlock";
296+
} else if (domain === "climate") {
297+
service = "set_temperature";
298+
if (temperature !== undefined) {
299+
body.temperature = temperature;
300+
}
301+
}
302+
303+
try {
304+
const haRes = await fetch(`${url}/api/services/${domain}/${service}`, {
305+
method: "POST",
306+
headers: {
307+
Authorization: `Bearer ${token}`,
308+
"Content-Type": "application/json",
309+
},
310+
body: JSON.stringify(body),
311+
});
312+
313+
if (!haRes.ok) {
314+
return `Failed to execute control service on Home Assistant. Status: ${haRes.status}`;
315+
}
316+
} catch (err: any) {
317+
return `Error contacting Home Assistant API: ${err.message || err}`;
318+
}
319+
}
320+
321+
// Persist the updated state back to the database
322+
metadata.devices = {
323+
...devices,
324+
[entityId]: updatedDevice,
325+
};
326+
327+
await db
328+
.update(connectorAccounts)
329+
.set({
330+
metadataJson: JSON.stringify(metadata),
331+
updatedAt: new Date().toISOString(),
332+
})
333+
.where(eq(connectorAccounts.id, account.id));
334+
335+
return `Successfully set ${entityId} to state: "${state}"${brightness !== undefined ? ` with brightness ${brightness}` : ""}${temperature !== undefined ? ` at temperature ${temperature}°C` : ""}.`;
336+
} catch (err: any) {
337+
return `Error controlling IoT device: ${err.message || err}`;
338+
}
339+
},
340+
{
341+
name: "control_iot_device",
342+
description: "Control a connected smart home device by changing its state (e.g. turn on a light, lock a door, set thermostat temperature).",
343+
schema: z.object({
344+
entityId: z.string().describe("The entity ID of the device (e.g., 'light.living_room', 'switch.kitchen_fan', 'lock.front_door', 'climate.thermostat')"),
345+
state: z.string().describe("The target state for the device (e.g., 'on', 'off', 'lock', 'unlock', 'heat')"),
346+
brightness: z.number().min(0).max(255).optional().describe("Optional brightness level (0-255) for lights only"),
347+
temperature: z.number().optional().describe("Optional target temperature value for climate/thermostat devices only"),
348+
}),
349+
}
350+
);
351+
212352
export async function POST(req: Request) {
213353
const session = await requireSession(req);
214354
if (session instanceof NextResponse) return session;
@@ -351,6 +491,8 @@ export async function POST(req: Request) {
351491
queryVoiceNotesTool(user.id, workspaceId),
352492
queryMailTool(user.id, workspaceId),
353493
queryConnectorsTool(user.id, workspaceId),
494+
getIotDeviceStatesTool(user.id, workspaceId),
495+
controlIotDeviceTool(user.id, workspaceId),
354496
...composioTools,
355497
...customMcpTools,
356498
];

0 commit comments

Comments
 (0)