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,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
|