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.
Files changed (71) hide show
  1. ha_mcp/__init__.py +51 -0
  2. ha_mcp/__main__.py +753 -0
  3. ha_mcp/_pypi_marker +0 -0
  4. ha_mcp/auth/__init__.py +10 -0
  5. ha_mcp/auth/consent_form.py +501 -0
  6. ha_mcp/auth/provider.py +786 -0
  7. ha_mcp/client/__init__.py +10 -0
  8. ha_mcp/client/rest_client.py +924 -0
  9. ha_mcp/client/websocket_client.py +656 -0
  10. ha_mcp/client/websocket_listener.py +340 -0
  11. ha_mcp/config.py +175 -0
  12. ha_mcp/errors.py +399 -0
  13. ha_mcp/py.typed +0 -0
  14. ha_mcp/resources/card_types.json +48 -0
  15. ha_mcp/resources/dashboard_guide.md +573 -0
  16. ha_mcp/server.py +189 -0
  17. ha_mcp/smoke_test.py +108 -0
  18. ha_mcp/tools/__init__.py +11 -0
  19. ha_mcp/tools/backup.py +457 -0
  20. ha_mcp/tools/device_control.py +687 -0
  21. ha_mcp/tools/enhanced.py +191 -0
  22. ha_mcp/tools/helpers.py +184 -0
  23. ha_mcp/tools/registry.py +199 -0
  24. ha_mcp/tools/smart_search.py +797 -0
  25. ha_mcp/tools/tools_addons.py +343 -0
  26. ha_mcp/tools/tools_areas.py +478 -0
  27. ha_mcp/tools/tools_blueprints.py +279 -0
  28. ha_mcp/tools/tools_bug_report.py +570 -0
  29. ha_mcp/tools/tools_calendar.py +391 -0
  30. ha_mcp/tools/tools_camera.py +149 -0
  31. ha_mcp/tools/tools_config_automations.py +508 -0
  32. ha_mcp/tools/tools_config_dashboards.py +1502 -0
  33. ha_mcp/tools/tools_config_entry_flow.py +223 -0
  34. ha_mcp/tools/tools_config_helpers.py +925 -0
  35. ha_mcp/tools/tools_config_info.py +258 -0
  36. ha_mcp/tools/tools_config_scripts.py +267 -0
  37. ha_mcp/tools/tools_entities.py +76 -0
  38. ha_mcp/tools/tools_filesystem.py +589 -0
  39. ha_mcp/tools/tools_groups.py +306 -0
  40. ha_mcp/tools/tools_hacs.py +787 -0
  41. ha_mcp/tools/tools_history.py +714 -0
  42. ha_mcp/tools/tools_integrations.py +297 -0
  43. ha_mcp/tools/tools_labels.py +713 -0
  44. ha_mcp/tools/tools_mcp_component.py +305 -0
  45. ha_mcp/tools/tools_registry.py +1115 -0
  46. ha_mcp/tools/tools_resources.py +622 -0
  47. ha_mcp/tools/tools_search.py +573 -0
  48. ha_mcp/tools/tools_service.py +211 -0
  49. ha_mcp/tools/tools_services.py +352 -0
  50. ha_mcp/tools/tools_system.py +363 -0
  51. ha_mcp/tools/tools_todo.py +530 -0
  52. ha_mcp/tools/tools_traces.py +496 -0
  53. ha_mcp/tools/tools_updates.py +476 -0
  54. ha_mcp/tools/tools_utility.py +519 -0
  55. ha_mcp/tools/tools_voice_assistant.py +345 -0
  56. ha_mcp/tools/tools_zones.py +387 -0
  57. ha_mcp/tools/util_helpers.py +199 -0
  58. ha_mcp/utils/__init__.py +30 -0
  59. ha_mcp/utils/domain_handlers.py +380 -0
  60. ha_mcp/utils/fuzzy_search.py +339 -0
  61. ha_mcp/utils/operation_manager.py +442 -0
  62. ha_mcp/utils/usage_logger.py +290 -0
  63. ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
  64. ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
  65. ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
  66. ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
  67. ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
  68. ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
  69. tests/__init__.py +1 -0
  70. tests/test_constants.py +15 -0
  71. tests/test_env_manager.py +336 -0
@@ -0,0 +1,476 @@
1
+ """
2
+ Update management tools for Home Assistant MCP server.
3
+
4
+ This module provides tools for listing available updates, getting release notes,
5
+ and retrieving system version information.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ from typing import Annotated, Any
11
+
12
+ import httpx
13
+ from pydantic import Field
14
+
15
+ from .helpers import log_tool_usage
16
+ from .util_helpers import coerce_bool_param
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def register_update_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
22
+ """Register Home Assistant update management tools."""
23
+
24
+ async def _list_updates(include_skipped: bool) -> dict[str, Any]:
25
+ """Internal helper to list all update entities."""
26
+ # Get all entity states
27
+ states = await client.get_states()
28
+
29
+ # Filter for update domain entities
30
+ update_entities = [
31
+ s for s in states if s.get("entity_id", "").startswith("update.")
32
+ ]
33
+
34
+ available_updates = []
35
+ skipped_updates = []
36
+
37
+ for entity in update_entities:
38
+ entity_id = entity.get("entity_id", "")
39
+ state = entity.get("state", "")
40
+ attributes = entity.get("attributes", {})
41
+
42
+ # State "on" means update available
43
+ is_available = state == "on"
44
+ is_skipped = attributes.get("skipped_version") is not None
45
+
46
+ update_info = {
47
+ "entity_id": entity_id,
48
+ "title": attributes.get("title", entity_id),
49
+ "installed_version": attributes.get("installed_version"),
50
+ "latest_version": attributes.get("latest_version"),
51
+ "release_summary": attributes.get("release_summary"),
52
+ "release_url": attributes.get("release_url"),
53
+ "can_install": not attributes.get("in_progress", False),
54
+ "in_progress": attributes.get("in_progress", False),
55
+ "supports_release_notes": _supports_release_notes(
56
+ entity_id, attributes
57
+ ),
58
+ "skipped_version": attributes.get("skipped_version"),
59
+ "auto_update": attributes.get("auto_update", False),
60
+ }
61
+
62
+ # Categorize the update
63
+ update_info["category"] = _categorize_update(entity_id, attributes)
64
+
65
+ if is_skipped:
66
+ skipped_updates.append(update_info)
67
+ elif is_available:
68
+ available_updates.append(update_info)
69
+
70
+ # Include skipped updates if requested
71
+ all_updates = available_updates.copy()
72
+ if include_skipped:
73
+ all_updates.extend(skipped_updates)
74
+
75
+ # Group by category
76
+ categories: dict[str, list[dict[str, Any]]] = {
77
+ "core": [],
78
+ "os": [],
79
+ "supervisor": [],
80
+ "addons": [],
81
+ "hacs": [],
82
+ "devices": [],
83
+ "other": [],
84
+ }
85
+
86
+ for update in all_updates:
87
+ category = update.get("category", "other")
88
+ if category in categories:
89
+ categories[category].append(update)
90
+ else:
91
+ categories["other"].append(update)
92
+
93
+ # Remove empty categories
94
+ categories = {k: v for k, v in categories.items() if v}
95
+
96
+ return {
97
+ "success": True,
98
+ "updates_available": len(available_updates),
99
+ "skipped_count": len(skipped_updates),
100
+ "updates": all_updates,
101
+ "categories": categories,
102
+ "include_skipped": include_skipped,
103
+ }
104
+
105
+ async def _get_update_details(entity_id: str) -> dict[str, Any]:
106
+ """Internal helper to get details for a specific update entity."""
107
+ # Validate entity_id format
108
+ if not entity_id.startswith("update."):
109
+ return {
110
+ "success": False,
111
+ "entity_id": entity_id,
112
+ "error": "Invalid entity_id format. Must start with 'update.'",
113
+ }
114
+
115
+ # Get entity state to check if it exists and get attributes
116
+ entity_state = await client.get_entity_state(entity_id)
117
+ attributes = entity_state.get("attributes", {})
118
+ latest_version = attributes.get("latest_version", "unknown")
119
+ state = entity_state.get("state", "")
120
+
121
+ # Build basic update info
122
+ result: dict[str, Any] = {
123
+ "success": True,
124
+ "entity_id": entity_id,
125
+ "title": attributes.get("title", entity_id),
126
+ "state": state,
127
+ "update_available": state == "on",
128
+ "installed_version": attributes.get("installed_version"),
129
+ "latest_version": latest_version,
130
+ "release_summary": attributes.get("release_summary"),
131
+ "release_url": attributes.get("release_url"),
132
+ "can_install": not attributes.get("in_progress", False),
133
+ "in_progress": attributes.get("in_progress", False),
134
+ "skipped_version": attributes.get("skipped_version"),
135
+ "auto_update": attributes.get("auto_update", False),
136
+ "category": _categorize_update(entity_id, attributes),
137
+ }
138
+
139
+ # Try to fetch release notes
140
+ release_notes = None
141
+ release_notes_source = None
142
+
143
+ # Try WebSocket update/release_notes first
144
+ try:
145
+ ws_result = await client.send_websocket_message(
146
+ {
147
+ "type": "update/release_notes",
148
+ "entity_id": entity_id,
149
+ }
150
+ )
151
+
152
+ if ws_result.get("success") and ws_result.get("result"):
153
+ release_notes = ws_result.get("result")
154
+ release_notes_source = "websocket"
155
+
156
+ except Exception as ws_error:
157
+ logger.debug(
158
+ f"WebSocket release_notes failed for {entity_id}: {ws_error}"
159
+ )
160
+
161
+ # Fallback: Try to fetch from GitHub if release_url is available
162
+ if not release_notes:
163
+ release_url = attributes.get("release_url")
164
+ if release_url:
165
+ github_result = await _fetch_github_release_notes(release_url)
166
+ if github_result:
167
+ release_notes = github_result["notes"]
168
+ release_notes_source = github_result["source"]
169
+
170
+ # Special handling for Home Assistant Core updates
171
+ if not release_notes and "core" in entity_id.lower():
172
+ core_result = await _fetch_core_release_notes(latest_version)
173
+ if core_result:
174
+ release_notes = core_result["notes"]
175
+ release_notes_source = core_result["source"]
176
+
177
+ if release_notes:
178
+ result["release_notes"] = release_notes
179
+ result["release_notes_source"] = release_notes_source
180
+ else:
181
+ release_url = attributes.get("release_url")
182
+ if release_url:
183
+ result["release_notes_hint"] = (
184
+ f"Release notes could not be fetched automatically. "
185
+ f"View them at: {release_url}"
186
+ )
187
+
188
+ return result
189
+
190
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["update"], "title": "Get Updates"})
191
+ @log_tool_usage
192
+ async def ha_get_updates(
193
+ entity_id: Annotated[
194
+ str | None,
195
+ Field(
196
+ description="Update entity ID to get details for (e.g., 'update.home_assistant_core_update'). "
197
+ "If omitted, lists all available updates.",
198
+ default=None,
199
+ ),
200
+ ] = None,
201
+ include_skipped: Annotated[
202
+ bool | str,
203
+ Field(
204
+ description="When listing all updates, include updates that have been skipped (default: False)",
205
+ default=False,
206
+ ),
207
+ ] = False,
208
+ ) -> dict[str, Any]:
209
+ """
210
+ Get update information - list all updates or get details for a specific one.
211
+
212
+ Without an entity_id: Lists all available updates across the system including
213
+ Home Assistant Core, add-ons, device firmware, HACS, and OS updates.
214
+
215
+ With an entity_id: Returns detailed information about a specific update including
216
+ version info, category, and release notes (if available).
217
+
218
+ EXAMPLES:
219
+ - List all updates: ha_get_updates()
220
+ - List including skipped: ha_get_updates(include_skipped=True)
221
+ - Get specific update: ha_get_updates(entity_id="update.home_assistant_core_update")
222
+
223
+ RETURNS (when listing):
224
+ - updates_available: Count of available updates
225
+ - updates: List of update entities with version info
226
+ - categories: Updates grouped by category (core, addons, devices, hacs, os)
227
+
228
+ RETURNS (when getting specific update):
229
+ - Update details including installed/latest versions
230
+ - Release notes (fetched from WebSocket API or GitHub)
231
+ - Category and installation status
232
+ """
233
+ try:
234
+ if entity_id is None:
235
+ # List mode: return all updates
236
+ include_skipped_bool = coerce_bool_param(
237
+ include_skipped, "include_skipped", default=False
238
+ ) or False
239
+ return await _list_updates(include_skipped_bool)
240
+ else:
241
+ # Get mode: return details for specific update
242
+ return await _get_update_details(entity_id)
243
+
244
+ except Exception as e:
245
+ error_msg = str(e)
246
+ if entity_id and ("404" in error_msg or "not found" in error_msg.lower()):
247
+ return {
248
+ "success": False,
249
+ "entity_id": entity_id,
250
+ "error": f"Update entity not found: {entity_id}",
251
+ "suggestion": "Use ha_get_updates() without entity_id to see all available updates",
252
+ }
253
+ logger.error(f"Failed to get updates: {e}")
254
+ return {
255
+ "success": False,
256
+ "error": f"Failed to get updates: {str(e)}",
257
+ "suggestions": [
258
+ "Check Home Assistant connection",
259
+ "Verify API access permissions",
260
+ ],
261
+ }
262
+
263
+ def _supports_release_notes(entity_id: str, attributes: dict[str, Any]) -> bool:
264
+ """
265
+ Determine if an update entity supports fetching release notes.
266
+
267
+ Returns True if the entity supports release notes through any method:
268
+ - WebSocket update/release_notes command (native HA support)
269
+ - GitHub API/raw CDN fallback (when release_url is available)
270
+
271
+ Most entities will return True as they have either native support or a release_url.
272
+ """
273
+ # Check for supported_features that indicate release notes support
274
+ # Feature flag 1 = install, 2 = specific_version, 4 = progress, 8 = backup
275
+ # 16 = release_notes (0x10)
276
+ supported_features = attributes.get("supported_features", 0)
277
+ has_release_notes_feature = (supported_features & 16) != 0
278
+
279
+ # Entity supports release notes if it has either:
280
+ # 1. Native WebSocket support (feature flag)
281
+ # 2. A release_url (can fetch from GitHub)
282
+ return has_release_notes_feature or attributes.get("release_url") is not None
283
+
284
+
285
+ def _categorize_update(entity_id: str, attributes: dict[str, Any]) -> str:
286
+ """Categorize an update entity based on its entity_id and attributes."""
287
+ entity_lower = entity_id.lower()
288
+ # Use 'or ""' to handle both missing keys AND explicit None values
289
+ title_lower = (attributes.get("title") or "").lower()
290
+
291
+ # Core update
292
+ if "home_assistant_core" in entity_lower or (
293
+ "core" in entity_lower and "home_assistant" in title_lower
294
+ ):
295
+ return "core"
296
+
297
+ # Operating System
298
+ if "operating_system" in entity_lower or "haos" in entity_lower:
299
+ return "os"
300
+
301
+ # Supervisor
302
+ if "supervisor" in entity_lower:
303
+ return "supervisor"
304
+
305
+ # HACS updates
306
+ if "hacs" in entity_lower:
307
+ return "hacs"
308
+
309
+ # Add-ons (typically named update.xxx_update where xxx is addon name)
310
+ # Add-ons usually have "Add-on" in title or specific patterns
311
+ if "add-on" in title_lower or "addon" in title_lower:
312
+ return "addons"
313
+
314
+ # Device firmware updates (ESPHome, Z-Wave, Zigbee, etc.)
315
+ device_patterns = ["esphome", "zwave", "zigbee", "zha", "matter", "firmware"]
316
+ if any(
317
+ pattern in entity_lower or pattern in title_lower for pattern in device_patterns
318
+ ):
319
+ return "devices"
320
+
321
+ # Default to other
322
+ return "other"
323
+
324
+
325
+ async def _fetch_github_release_notes(release_url: str) -> dict[str, str] | None:
326
+ """
327
+ Fetch release notes from GitHub releases API with fallback to raw CDN.
328
+
329
+ Tries multiple sources in order:
330
+ 1. GitHub API (best formatting, but rate limited)
331
+ 2. GitHub raw content CDN (no rate limits, but may not have release notes)
332
+
333
+ Parses GitHub release URLs and fetches the release body from the API.
334
+
335
+ Args:
336
+ release_url: URL to a GitHub release page
337
+
338
+ Returns:
339
+ Dictionary with 'notes' and 'source' keys, or None if fetch fails
340
+ """
341
+ try:
342
+ # Parse GitHub URL patterns:
343
+ # https://github.com/owner/repo/releases/tag/v1.2.3
344
+ # https://github.com/owner/repo/releases/v1.2.3
345
+
346
+ github_pattern = r"https://github\.com/([^/]+)/([^/]+)/releases(?:/tag)?/([^/?#]+)"
347
+ match = re.match(github_pattern, release_url)
348
+
349
+ if not match:
350
+ logger.debug(f"Could not parse GitHub URL: {release_url}")
351
+ return None
352
+
353
+ owner, repo, tag = match.groups()
354
+
355
+ async with httpx.AsyncClient(timeout=15.0) as http_client:
356
+ # Try 1: GitHub API (has release notes in structured format)
357
+ api_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
358
+
359
+ response = await http_client.get(
360
+ api_url,
361
+ headers={
362
+ "Accept": "application/vnd.github+json",
363
+ "User-Agent": "HomeAssistant-MCP-Server",
364
+ },
365
+ )
366
+
367
+ if response.status_code == 200:
368
+ release_data = response.json()
369
+ body = release_data.get("body", "")
370
+ if body:
371
+ return {"notes": str(body), "source": "github_api"}
372
+ elif response.status_code == 403:
373
+ # Check if rate limited
374
+ remaining = response.headers.get("X-RateLimit-Remaining", "0")
375
+ if remaining == "0":
376
+ logger.warning(
377
+ f"GitHub API rate limit exceeded for {api_url}, trying raw CDN fallback"
378
+ )
379
+ else:
380
+ logger.debug(
381
+ f"GitHub API returned status {response.status_code} for {api_url}"
382
+ )
383
+
384
+ # Try 2: GitHub raw content CDN (for markdown files)
385
+ # Common locations: CHANGELOG.md, RELEASES.md, docs/releases/{tag}.md
386
+ raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{tag}"
387
+
388
+ changelog_paths = [
389
+ "CHANGELOG.md",
390
+ "RELEASES.md",
391
+ "RELEASE_NOTES.md",
392
+ f"docs/releases/{tag}.md",
393
+ "docs/CHANGELOG.md",
394
+ ]
395
+
396
+ for path in changelog_paths:
397
+ raw_url = f"{raw_base}/{path}"
398
+ try:
399
+ response = await http_client.get(
400
+ raw_url,
401
+ headers={"User-Agent": "HomeAssistant-MCP-Server"},
402
+ )
403
+
404
+ if response.status_code == 200:
405
+ content = response.text
406
+ if content and len(content) > 50: # Basic content validation
407
+ logger.debug(
408
+ f"Successfully fetched release notes from raw CDN: {raw_url}"
409
+ )
410
+ return {"notes": content, "source": "github_raw"}
411
+ except Exception as raw_error:
412
+ logger.debug(f"Failed to fetch from {raw_url}: {raw_error}")
413
+ continue
414
+
415
+ logger.debug(
416
+ f"Could not fetch release notes from API or raw CDN for {release_url}"
417
+ )
418
+ return None
419
+
420
+ except Exception as e:
421
+ logger.debug(f"Failed to fetch GitHub release notes: {e}")
422
+ return None
423
+
424
+
425
+ async def _fetch_core_release_notes(version: str) -> dict[str, str] | None:
426
+ """
427
+ Fetch release notes for Home Assistant Core from GitHub releases API.
428
+
429
+ Home Assistant Core uses blog URLs for release_url which don't contain
430
+ the actual release notes. This function fetches directly from GitHub
431
+ releases using the version tag.
432
+
433
+ Args:
434
+ version: The version string (e.g., "2025.11.3")
435
+
436
+ Returns:
437
+ Dictionary with 'notes' and 'source' keys, or None if fetch fails
438
+ """
439
+ try:
440
+ async with httpx.AsyncClient(timeout=15.0) as http_client:
441
+ # GitHub API URL for Home Assistant Core releases
442
+ api_url = f"https://api.github.com/repos/home-assistant/core/releases/tags/{version}"
443
+
444
+ response = await http_client.get(
445
+ api_url,
446
+ headers={
447
+ "Accept": "application/vnd.github+json",
448
+ "User-Agent": "HomeAssistant-MCP-Server",
449
+ },
450
+ )
451
+
452
+ if response.status_code == 200:
453
+ release_data = response.json()
454
+ body = release_data.get("body", "")
455
+ if body:
456
+ logger.debug(
457
+ f"Successfully fetched Core release notes from GitHub for version {version}"
458
+ )
459
+ return {"notes": str(body), "source": "github_api"}
460
+ elif response.status_code == 403:
461
+ # Check if rate limited
462
+ remaining = response.headers.get("X-RateLimit-Remaining", "0")
463
+ if remaining == "0":
464
+ logger.warning(
465
+ f"GitHub API rate limit exceeded for {api_url}"
466
+ )
467
+ else:
468
+ logger.debug(
469
+ f"GitHub API returned status {response.status_code} for Core release {version}"
470
+ )
471
+
472
+ return None
473
+
474
+ except Exception as e:
475
+ logger.debug(f"Failed to fetch Core release notes from GitHub: {e}")
476
+ return None