ha-mcp-dev 6.3.1.dev137__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ha_mcp/__init__.py +51 -0
- ha_mcp/__main__.py +753 -0
- ha_mcp/_pypi_marker +0 -0
- ha_mcp/auth/__init__.py +10 -0
- ha_mcp/auth/consent_form.py +501 -0
- ha_mcp/auth/provider.py +786 -0
- ha_mcp/client/__init__.py +10 -0
- ha_mcp/client/rest_client.py +924 -0
- ha_mcp/client/websocket_client.py +656 -0
- ha_mcp/client/websocket_listener.py +340 -0
- ha_mcp/config.py +175 -0
- ha_mcp/errors.py +399 -0
- ha_mcp/py.typed +0 -0
- ha_mcp/resources/card_types.json +48 -0
- ha_mcp/resources/dashboard_guide.md +573 -0
- ha_mcp/server.py +189 -0
- ha_mcp/smoke_test.py +108 -0
- ha_mcp/tools/__init__.py +11 -0
- ha_mcp/tools/backup.py +457 -0
- ha_mcp/tools/device_control.py +687 -0
- ha_mcp/tools/enhanced.py +191 -0
- ha_mcp/tools/helpers.py +184 -0
- ha_mcp/tools/registry.py +199 -0
- ha_mcp/tools/smart_search.py +797 -0
- ha_mcp/tools/tools_addons.py +343 -0
- ha_mcp/tools/tools_areas.py +478 -0
- ha_mcp/tools/tools_blueprints.py +279 -0
- ha_mcp/tools/tools_bug_report.py +570 -0
- ha_mcp/tools/tools_calendar.py +391 -0
- ha_mcp/tools/tools_camera.py +149 -0
- ha_mcp/tools/tools_config_automations.py +508 -0
- ha_mcp/tools/tools_config_dashboards.py +1502 -0
- ha_mcp/tools/tools_config_entry_flow.py +223 -0
- ha_mcp/tools/tools_config_helpers.py +925 -0
- ha_mcp/tools/tools_config_info.py +258 -0
- ha_mcp/tools/tools_config_scripts.py +267 -0
- ha_mcp/tools/tools_entities.py +76 -0
- ha_mcp/tools/tools_filesystem.py +589 -0
- ha_mcp/tools/tools_groups.py +306 -0
- ha_mcp/tools/tools_hacs.py +787 -0
- ha_mcp/tools/tools_history.py +714 -0
- ha_mcp/tools/tools_integrations.py +297 -0
- ha_mcp/tools/tools_labels.py +713 -0
- ha_mcp/tools/tools_mcp_component.py +305 -0
- ha_mcp/tools/tools_registry.py +1115 -0
- ha_mcp/tools/tools_resources.py +622 -0
- ha_mcp/tools/tools_search.py +573 -0
- ha_mcp/tools/tools_service.py +211 -0
- ha_mcp/tools/tools_services.py +352 -0
- ha_mcp/tools/tools_system.py +363 -0
- ha_mcp/tools/tools_todo.py +530 -0
- ha_mcp/tools/tools_traces.py +496 -0
- ha_mcp/tools/tools_updates.py +476 -0
- ha_mcp/tools/tools_utility.py +519 -0
- ha_mcp/tools/tools_voice_assistant.py +345 -0
- ha_mcp/tools/tools_zones.py +387 -0
- ha_mcp/tools/util_helpers.py +199 -0
- ha_mcp/utils/__init__.py +30 -0
- ha_mcp/utils/domain_handlers.py +380 -0
- ha_mcp/utils/fuzzy_search.py +339 -0
- ha_mcp/utils/operation_manager.py +442 -0
- ha_mcp/utils/usage_logger.py +290 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_constants.py +15 -0
- tests/test_env_manager.py +336 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Calendar event management tools for Home Assistant MCP server.
|
|
3
|
+
|
|
4
|
+
This module provides tools for managing calendar events in Home Assistant,
|
|
5
|
+
including retrieving events, creating events, and deleting events.
|
|
6
|
+
|
|
7
|
+
Use ha_search_entities(query='calendar', domain_filter='calendar') to find calendar entities.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from typing import Annotated, Any
|
|
13
|
+
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
|
|
16
|
+
from .helpers import log_tool_usage
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_calendar_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
22
|
+
"""Register calendar management tools with the MCP server."""
|
|
23
|
+
|
|
24
|
+
@mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["calendar"], "title": "Get Calendar Events"})
|
|
25
|
+
@log_tool_usage
|
|
26
|
+
async def ha_config_get_calendar_events(
|
|
27
|
+
entity_id: Annotated[
|
|
28
|
+
str, Field(description="Calendar entity ID (e.g., 'calendar.family')")
|
|
29
|
+
],
|
|
30
|
+
start: Annotated[
|
|
31
|
+
str | None,
|
|
32
|
+
Field(
|
|
33
|
+
description="Start datetime in ISO format (default: now)", default=None
|
|
34
|
+
),
|
|
35
|
+
] = None,
|
|
36
|
+
end: Annotated[
|
|
37
|
+
str | None,
|
|
38
|
+
Field(
|
|
39
|
+
description="End datetime in ISO format (default: 7 days from start)",
|
|
40
|
+
default=None,
|
|
41
|
+
),
|
|
42
|
+
] = None,
|
|
43
|
+
max_results: Annotated[
|
|
44
|
+
int,
|
|
45
|
+
Field(description="Maximum number of events to return", default=20),
|
|
46
|
+
] = 20,
|
|
47
|
+
) -> dict[str, Any]:
|
|
48
|
+
"""
|
|
49
|
+
Retrieve calendar events from a calendar entity.
|
|
50
|
+
|
|
51
|
+
Retrieves calendar events within a specified time range.
|
|
52
|
+
|
|
53
|
+
**Parameters:**
|
|
54
|
+
- entity_id: Calendar entity ID (e.g., 'calendar.family')
|
|
55
|
+
- start: Start datetime in ISO format (default: now)
|
|
56
|
+
- end: End datetime in ISO format (default: 7 days from start)
|
|
57
|
+
- max_results: Maximum number of events to return (default: 20)
|
|
58
|
+
|
|
59
|
+
**Example Usage:**
|
|
60
|
+
```python
|
|
61
|
+
# Get events for the next week
|
|
62
|
+
events = ha_config_get_calendar_events("calendar.family")
|
|
63
|
+
|
|
64
|
+
# Get events for a specific date range
|
|
65
|
+
events = ha_config_get_calendar_events(
|
|
66
|
+
"calendar.work",
|
|
67
|
+
start="2024-01-01T00:00:00",
|
|
68
|
+
end="2024-01-31T23:59:59"
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Note:** To find calendar entities, use ha_search_entities(query='calendar', domain_filter='calendar')
|
|
73
|
+
|
|
74
|
+
**Returns:**
|
|
75
|
+
- List of calendar events with summary, start, end, description, location
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
# Validate entity_id
|
|
79
|
+
if not entity_id.startswith("calendar."):
|
|
80
|
+
return {
|
|
81
|
+
"success": False,
|
|
82
|
+
"error": f"Invalid calendar entity ID: {entity_id}. Must start with 'calendar.'",
|
|
83
|
+
"entity_id": entity_id,
|
|
84
|
+
"suggestions": [
|
|
85
|
+
"Use ha_search_entities(query='calendar', domain_filter='calendar') to find calendar entities",
|
|
86
|
+
"Calendar entity IDs start with 'calendar.' prefix",
|
|
87
|
+
],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Set default time range if not provided
|
|
91
|
+
now = datetime.now()
|
|
92
|
+
if start is None:
|
|
93
|
+
start = now.isoformat()
|
|
94
|
+
if end is None:
|
|
95
|
+
end_date = now + timedelta(days=7)
|
|
96
|
+
end = end_date.isoformat()
|
|
97
|
+
|
|
98
|
+
# Build the API endpoint for calendar events
|
|
99
|
+
# Home Assistant uses: GET /api/calendars/{entity_id}?start=...&end=...
|
|
100
|
+
params = {"start": start, "end": end}
|
|
101
|
+
|
|
102
|
+
# Use the REST client to fetch calendar events
|
|
103
|
+
# The endpoint is /calendars/{entity_id} (note: without /api prefix as client adds it)
|
|
104
|
+
response = await client._request(
|
|
105
|
+
"GET", f"/calendars/{entity_id}", params=params
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Response is a list of events
|
|
109
|
+
events = response if isinstance(response, list) else []
|
|
110
|
+
|
|
111
|
+
# Limit results
|
|
112
|
+
limited_events = events[:max_results]
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"success": True,
|
|
116
|
+
"entity_id": entity_id,
|
|
117
|
+
"events": limited_events,
|
|
118
|
+
"count": len(limited_events),
|
|
119
|
+
"total_available": len(events),
|
|
120
|
+
"time_range": {
|
|
121
|
+
"start": start,
|
|
122
|
+
"end": end,
|
|
123
|
+
},
|
|
124
|
+
"message": f"Retrieved {len(limited_events)} event(s) from {entity_id}",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
except Exception as error:
|
|
128
|
+
error_str = str(error)
|
|
129
|
+
logger.error(f"Failed to get calendar events for {entity_id}: {error}")
|
|
130
|
+
|
|
131
|
+
# Provide helpful error messages
|
|
132
|
+
suggestions = [
|
|
133
|
+
f"Verify calendar entity '{entity_id}' exists using ha_search_entities(query='calendar', domain_filter='calendar')",
|
|
134
|
+
"Check start/end datetime format (ISO 8601)",
|
|
135
|
+
"Ensure calendar integration supports event retrieval",
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
if "404" in error_str or "not found" in error_str.lower():
|
|
139
|
+
suggestions.insert(0, f"Calendar entity '{entity_id}' not found")
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"success": False,
|
|
143
|
+
"error": error_str,
|
|
144
|
+
"entity_id": entity_id,
|
|
145
|
+
"events": [],
|
|
146
|
+
"suggestions": suggestions,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@mcp.tool(annotations={"destructiveHint": True, "tags": ["calendar"], "title": "Create or Update Calendar Event"})
|
|
150
|
+
@log_tool_usage
|
|
151
|
+
async def ha_config_set_calendar_event(
|
|
152
|
+
entity_id: Annotated[
|
|
153
|
+
str, Field(description="Calendar entity ID (e.g., 'calendar.family')")
|
|
154
|
+
],
|
|
155
|
+
summary: Annotated[str, Field(description="Event title/summary")],
|
|
156
|
+
start: Annotated[
|
|
157
|
+
str, Field(description="Event start datetime in ISO format")
|
|
158
|
+
],
|
|
159
|
+
end: Annotated[str, Field(description="Event end datetime in ISO format")],
|
|
160
|
+
description: Annotated[
|
|
161
|
+
str | None,
|
|
162
|
+
Field(description="Optional event description", default=None),
|
|
163
|
+
] = None,
|
|
164
|
+
location: Annotated[
|
|
165
|
+
str | None, Field(description="Optional event location", default=None)
|
|
166
|
+
] = None,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""
|
|
169
|
+
Create a new event in a calendar.
|
|
170
|
+
|
|
171
|
+
Creates a calendar event using the calendar.create_event service.
|
|
172
|
+
|
|
173
|
+
**Parameters:**
|
|
174
|
+
- entity_id: Calendar entity ID (e.g., 'calendar.family')
|
|
175
|
+
- summary: Event title/summary
|
|
176
|
+
- start: Event start datetime in ISO format
|
|
177
|
+
- end: Event end datetime in ISO format
|
|
178
|
+
- description: Optional event description
|
|
179
|
+
- location: Optional event location
|
|
180
|
+
|
|
181
|
+
**Example Usage:**
|
|
182
|
+
```python
|
|
183
|
+
# Create a simple event
|
|
184
|
+
result = ha_config_set_calendar_event(
|
|
185
|
+
"calendar.family",
|
|
186
|
+
summary="Doctor appointment",
|
|
187
|
+
start="2024-01-15T14:00:00",
|
|
188
|
+
end="2024-01-15T15:00:00"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Create an event with details
|
|
192
|
+
result = ha_config_set_calendar_event(
|
|
193
|
+
"calendar.work",
|
|
194
|
+
summary="Team meeting",
|
|
195
|
+
start="2024-01-16T10:00:00",
|
|
196
|
+
end="2024-01-16T11:00:00",
|
|
197
|
+
description="Weekly sync meeting",
|
|
198
|
+
location="Conference Room A"
|
|
199
|
+
)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Returns:**
|
|
203
|
+
- Success status and event details
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
# Validate entity_id
|
|
207
|
+
if not entity_id.startswith("calendar."):
|
|
208
|
+
return {
|
|
209
|
+
"success": False,
|
|
210
|
+
"error": f"Invalid calendar entity ID: {entity_id}. Must start with 'calendar.'",
|
|
211
|
+
"entity_id": entity_id,
|
|
212
|
+
"suggestions": [
|
|
213
|
+
"Use ha_search_entities(query='calendar', domain_filter='calendar') to find calendar entities",
|
|
214
|
+
"Calendar entity IDs start with 'calendar.' prefix",
|
|
215
|
+
],
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# Build service data
|
|
219
|
+
service_data: dict[str, Any] = {
|
|
220
|
+
"entity_id": entity_id,
|
|
221
|
+
"summary": summary,
|
|
222
|
+
"start_date_time": start,
|
|
223
|
+
"end_date_time": end,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if description:
|
|
227
|
+
service_data["description"] = description
|
|
228
|
+
if location:
|
|
229
|
+
service_data["location"] = location
|
|
230
|
+
|
|
231
|
+
# Call the calendar.create_event service
|
|
232
|
+
result = await client.call_service("calendar", "create_event", service_data)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"success": True,
|
|
236
|
+
"entity_id": entity_id,
|
|
237
|
+
"event": {
|
|
238
|
+
"summary": summary,
|
|
239
|
+
"start": start,
|
|
240
|
+
"end": end,
|
|
241
|
+
"description": description,
|
|
242
|
+
"location": location,
|
|
243
|
+
},
|
|
244
|
+
"result": result,
|
|
245
|
+
"message": f"Successfully created event '{summary}' in {entity_id}",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
except Exception as error:
|
|
249
|
+
error_str = str(error)
|
|
250
|
+
logger.error(f"Failed to create calendar event in {entity_id}: {error}")
|
|
251
|
+
|
|
252
|
+
suggestions = [
|
|
253
|
+
f"Verify calendar entity '{entity_id}' exists and supports event creation",
|
|
254
|
+
"Check datetime format (ISO 8601)",
|
|
255
|
+
"Ensure end time is after start time",
|
|
256
|
+
"Some calendar integrations may be read-only",
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
if "404" in error_str or "not found" in error_str.lower():
|
|
260
|
+
suggestions.insert(0, f"Calendar entity '{entity_id}' not found")
|
|
261
|
+
if "not supported" in error_str.lower():
|
|
262
|
+
suggestions.insert(0, "This calendar does not support event creation")
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
"success": False,
|
|
266
|
+
"error": error_str,
|
|
267
|
+
"entity_id": entity_id,
|
|
268
|
+
"event": {
|
|
269
|
+
"summary": summary,
|
|
270
|
+
"start": start,
|
|
271
|
+
"end": end,
|
|
272
|
+
},
|
|
273
|
+
"suggestions": suggestions,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
@mcp.tool(annotations={"destructiveHint": True, "idempotentHint": True, "tags": ["calendar"], "title": "Remove Calendar Event"})
|
|
277
|
+
@log_tool_usage
|
|
278
|
+
async def ha_config_remove_calendar_event(
|
|
279
|
+
entity_id: Annotated[
|
|
280
|
+
str, Field(description="Calendar entity ID (e.g., 'calendar.family')")
|
|
281
|
+
],
|
|
282
|
+
uid: Annotated[str, Field(description="Unique identifier of the event to delete")],
|
|
283
|
+
recurrence_id: Annotated[
|
|
284
|
+
str | None,
|
|
285
|
+
Field(description="Optional recurrence ID for recurring events", default=None),
|
|
286
|
+
] = None,
|
|
287
|
+
recurrence_range: Annotated[
|
|
288
|
+
str | None,
|
|
289
|
+
Field(
|
|
290
|
+
description="Optional recurrence range ('THIS_AND_FUTURE' to delete this and future occurrences)",
|
|
291
|
+
default=None,
|
|
292
|
+
),
|
|
293
|
+
] = None,
|
|
294
|
+
) -> dict[str, Any]:
|
|
295
|
+
"""
|
|
296
|
+
Delete an event from a calendar.
|
|
297
|
+
|
|
298
|
+
Deletes a calendar event using the calendar.delete_event service.
|
|
299
|
+
|
|
300
|
+
**Parameters:**
|
|
301
|
+
- entity_id: Calendar entity ID (e.g., 'calendar.family')
|
|
302
|
+
- uid: Unique identifier of the event to delete
|
|
303
|
+
- recurrence_id: Optional recurrence ID for recurring events
|
|
304
|
+
- recurrence_range: Optional recurrence range ('THIS_AND_FUTURE' to delete this and future occurrences)
|
|
305
|
+
|
|
306
|
+
**Example Usage:**
|
|
307
|
+
```python
|
|
308
|
+
# Delete a single event
|
|
309
|
+
result = ha_config_remove_calendar_event(
|
|
310
|
+
"calendar.family",
|
|
311
|
+
uid="event-12345"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Delete a recurring event instance and future occurrences
|
|
315
|
+
result = ha_config_remove_calendar_event(
|
|
316
|
+
"calendar.work",
|
|
317
|
+
uid="recurring-event-67890",
|
|
318
|
+
recurrence_id="20240115T100000",
|
|
319
|
+
recurrence_range="THIS_AND_FUTURE"
|
|
320
|
+
)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**Note:**
|
|
324
|
+
To get the event UID, first use ha_config_get_calendar_events() to list events.
|
|
325
|
+
The UID is returned in each event's data.
|
|
326
|
+
|
|
327
|
+
**Returns:**
|
|
328
|
+
- Success status and deletion confirmation
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
# Validate entity_id
|
|
332
|
+
if not entity_id.startswith("calendar."):
|
|
333
|
+
return {
|
|
334
|
+
"success": False,
|
|
335
|
+
"error": f"Invalid calendar entity ID: {entity_id}. Must start with 'calendar.'",
|
|
336
|
+
"entity_id": entity_id,
|
|
337
|
+
"suggestions": [
|
|
338
|
+
"Use ha_search_entities(query='calendar', domain_filter='calendar') to find calendar entities",
|
|
339
|
+
"Calendar entity IDs start with 'calendar.' prefix",
|
|
340
|
+
],
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
# Build service data
|
|
344
|
+
service_data: dict[str, Any] = {
|
|
345
|
+
"entity_id": entity_id,
|
|
346
|
+
"uid": uid,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if recurrence_id:
|
|
350
|
+
service_data["recurrence_id"] = recurrence_id
|
|
351
|
+
if recurrence_range:
|
|
352
|
+
service_data["recurrence_range"] = recurrence_range
|
|
353
|
+
|
|
354
|
+
# Call the calendar.delete_event service
|
|
355
|
+
result = await client.call_service("calendar", "delete_event", service_data)
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
"success": True,
|
|
359
|
+
"entity_id": entity_id,
|
|
360
|
+
"uid": uid,
|
|
361
|
+
"recurrence_id": recurrence_id,
|
|
362
|
+
"recurrence_range": recurrence_range,
|
|
363
|
+
"result": result,
|
|
364
|
+
"message": f"Successfully deleted event '{uid}' from {entity_id}",
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
except Exception as error:
|
|
368
|
+
error_str = str(error)
|
|
369
|
+
logger.error(f"Failed to delete calendar event from {entity_id}: {error}")
|
|
370
|
+
|
|
371
|
+
suggestions = [
|
|
372
|
+
f"Verify calendar entity '{entity_id}' exists",
|
|
373
|
+
f"Verify event with UID '{uid}' exists in the calendar",
|
|
374
|
+
"Use ha_config_get_calendar_events() to find the correct event UID",
|
|
375
|
+
"Some calendar integrations may not support event deletion",
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
if "404" in error_str or "not found" in error_str.lower():
|
|
379
|
+
suggestions.insert(
|
|
380
|
+
0, f"Calendar entity '{entity_id}' or event '{uid}' not found"
|
|
381
|
+
)
|
|
382
|
+
if "not supported" in error_str.lower():
|
|
383
|
+
suggestions.insert(0, "This calendar does not support event deletion")
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
"success": False,
|
|
387
|
+
"error": error_str,
|
|
388
|
+
"entity_id": entity_id,
|
|
389
|
+
"uid": uid,
|
|
390
|
+
"suggestions": suggestions,
|
|
391
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Camera tools for Home Assistant MCP server.
|
|
3
|
+
|
|
4
|
+
This module provides camera-related tools including snapshot retrieval
|
|
5
|
+
that returns images directly to the LLM for visual analysis.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastmcp.utilities.types import Image
|
|
12
|
+
|
|
13
|
+
from .helpers import log_tool_usage
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_camera_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
19
|
+
"""Register Home Assistant camera tools."""
|
|
20
|
+
|
|
21
|
+
@mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["camera"], "title": "Get Camera Image"})
|
|
22
|
+
@log_tool_usage
|
|
23
|
+
async def ha_get_camera_image(
|
|
24
|
+
entity_id: str,
|
|
25
|
+
width: int | None = None,
|
|
26
|
+
height: int | None = None,
|
|
27
|
+
) -> Image:
|
|
28
|
+
"""
|
|
29
|
+
Retrieve a snapshot image from a Home Assistant camera entity.
|
|
30
|
+
|
|
31
|
+
This tool fetches the current camera image and returns it directly for visual
|
|
32
|
+
analysis. Use this when you need to see what a camera is currently viewing.
|
|
33
|
+
|
|
34
|
+
**Parameters:**
|
|
35
|
+
- entity_id: Camera entity ID (e.g., 'camera.front_door', 'camera.living_room')
|
|
36
|
+
- width: Optional width to resize the image (reduces token usage for large images)
|
|
37
|
+
- height: Optional height to resize the image
|
|
38
|
+
|
|
39
|
+
**Use Cases:**
|
|
40
|
+
- Security checks: "Is someone at the front door?"
|
|
41
|
+
- Pet monitoring: "Is my dog still on the couch?"
|
|
42
|
+
- Delivery verification: "Did my package get delivered?"
|
|
43
|
+
- Visual confirmation: "Did the garage door actually close?"
|
|
44
|
+
- Incident investigation: "What triggered the motion sensor?"
|
|
45
|
+
|
|
46
|
+
**Example Usage:**
|
|
47
|
+
```python
|
|
48
|
+
# Get current snapshot from front door camera
|
|
49
|
+
ha_get_camera_image(entity_id="camera.front_door")
|
|
50
|
+
|
|
51
|
+
# Get resized image to reduce token usage
|
|
52
|
+
ha_get_camera_image(entity_id="camera.backyard", width=640, height=480)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Notes:**
|
|
56
|
+
- Only cameras exposed to Home Assistant are accessible
|
|
57
|
+
- The existing HA authentication/authorization applies
|
|
58
|
+
- Images are returned in their native format (JPEG, PNG, or GIF)
|
|
59
|
+
- Use width/height parameters for large high-resolution cameras to reduce
|
|
60
|
+
token usage when full resolution is not needed
|
|
61
|
+
|
|
62
|
+
**Related Services:**
|
|
63
|
+
- camera.snapshot: Save snapshot to file on HA server
|
|
64
|
+
- camera.turn_on/turn_off: Control camera power
|
|
65
|
+
- camera.enable_motion_detection: Enable motion detection
|
|
66
|
+
"""
|
|
67
|
+
# Validate entity_id format
|
|
68
|
+
if not entity_id or "." not in entity_id:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Invalid entity_id format: {entity_id}. "
|
|
71
|
+
"Expected format: camera.entity_name"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Validate domain is camera
|
|
75
|
+
domain = entity_id.split(".")[0]
|
|
76
|
+
if domain != "camera":
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Entity {entity_id} is not a camera entity. "
|
|
79
|
+
f"Domain is '{domain}', expected 'camera'."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Build the camera proxy URL with optional size parameters
|
|
83
|
+
# Home Assistant camera proxy API: /api/camera_proxy/<entity_id>
|
|
84
|
+
endpoint = f"/camera_proxy/{entity_id}"
|
|
85
|
+
|
|
86
|
+
params = {}
|
|
87
|
+
if width is not None:
|
|
88
|
+
params["width"] = str(width)
|
|
89
|
+
if height is not None:
|
|
90
|
+
params["height"] = str(height)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
# Use the client's httpx_client directly for binary image data
|
|
94
|
+
response = await client.httpx_client.get(endpoint, params=params or None)
|
|
95
|
+
|
|
96
|
+
# Handle authentication errors
|
|
97
|
+
if response.status_code == 401:
|
|
98
|
+
raise PermissionError("Invalid authentication token for camera access")
|
|
99
|
+
|
|
100
|
+
# Handle not found errors
|
|
101
|
+
if response.status_code == 404:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Camera entity not found: {entity_id}. "
|
|
104
|
+
"Use ha_search_entities() to find available cameras."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Handle other HTTP errors
|
|
108
|
+
if response.status_code >= 400:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
f"Failed to retrieve camera image: HTTP {response.status_code}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Get the image bytes
|
|
114
|
+
image_data = response.content
|
|
115
|
+
|
|
116
|
+
if not image_data:
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
f"Camera {entity_id} returned empty image data. "
|
|
119
|
+
"The camera may be offline or unavailable."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Determine MIME type from response headers or default to JPEG
|
|
123
|
+
content_type = response.headers.get("content-type", "image/jpeg")
|
|
124
|
+
if "jpeg" in content_type or "jpg" in content_type:
|
|
125
|
+
image_format = "jpeg"
|
|
126
|
+
elif "png" in content_type:
|
|
127
|
+
image_format = "png"
|
|
128
|
+
elif "gif" in content_type:
|
|
129
|
+
image_format = "gif"
|
|
130
|
+
else:
|
|
131
|
+
# Default to JPEG as Home Assistant camera proxy typically returns JPEG
|
|
132
|
+
image_format = "jpeg"
|
|
133
|
+
|
|
134
|
+
logger.info(
|
|
135
|
+
f"Retrieved camera image from {entity_id} "
|
|
136
|
+
f"({len(image_data)} bytes, format={image_format})"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Return FastMCP Image object which automatically converts to MCP ImageContent
|
|
140
|
+
return Image(data=image_data, format=image_format)
|
|
141
|
+
|
|
142
|
+
except (PermissionError, ValueError, RuntimeError):
|
|
143
|
+
raise
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error(f"Error retrieving camera image from {entity_id}: {e}")
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
f"Failed to retrieve camera image from {entity_id}: {str(e)}. "
|
|
148
|
+
"Ensure the camera is online and accessible."
|
|
149
|
+
)
|