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,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fuzzy entity search utilities for Home Assistant MCP server.
|
|
3
|
+
|
|
4
|
+
This module uses Python's built-in difflib for string similarity calculations,
|
|
5
|
+
eliminating the need for external dependencies like textdistance and numpy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from difflib import SequenceMatcher
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FuzzyEntitySearcher:
|
|
17
|
+
"""Advanced fuzzy entity search with AI-optimized scoring."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, threshold: int = 60):
|
|
20
|
+
"""Initialize with fuzzy matching threshold."""
|
|
21
|
+
self.threshold = threshold
|
|
22
|
+
self.entity_cache: dict[str, Any] = {}
|
|
23
|
+
|
|
24
|
+
def search_entities(
|
|
25
|
+
self, entities: list[dict[str, Any]], query: str, limit: int = 10
|
|
26
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
27
|
+
"""
|
|
28
|
+
Search entities with fuzzy matching and intelligent scoring.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
entities: List of Home Assistant entity states
|
|
32
|
+
query: Search query (can be partial, with typos)
|
|
33
|
+
limit: Maximum number of results
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tuple of (limited results list, total match count)
|
|
37
|
+
"""
|
|
38
|
+
if not query or not entities:
|
|
39
|
+
return [], 0
|
|
40
|
+
|
|
41
|
+
matches = []
|
|
42
|
+
query_lower = query.lower().strip()
|
|
43
|
+
|
|
44
|
+
for entity in entities:
|
|
45
|
+
entity_id = entity.get("entity_id", "")
|
|
46
|
+
attributes = entity.get("attributes", {})
|
|
47
|
+
friendly_name = attributes.get("friendly_name", entity_id)
|
|
48
|
+
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
|
49
|
+
|
|
50
|
+
# Calculate comprehensive score
|
|
51
|
+
score = self._calculate_entity_score(
|
|
52
|
+
entity_id, friendly_name, domain, query_lower
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if score >= self.threshold:
|
|
56
|
+
matches.append(
|
|
57
|
+
{
|
|
58
|
+
"entity_id": entity_id,
|
|
59
|
+
"friendly_name": friendly_name,
|
|
60
|
+
"domain": domain,
|
|
61
|
+
"state": entity.get("state", "unknown"),
|
|
62
|
+
"attributes": attributes,
|
|
63
|
+
"score": score,
|
|
64
|
+
"match_type": self._get_match_type(
|
|
65
|
+
entity_id, friendly_name, domain, query_lower
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Sort by score descending
|
|
71
|
+
matches.sort(key=lambda x: x["score"], reverse=True)
|
|
72
|
+
total_matches = len(matches)
|
|
73
|
+
return matches[:limit], total_matches
|
|
74
|
+
|
|
75
|
+
def _calculate_entity_score(
|
|
76
|
+
self, entity_id: str, friendly_name: str, domain: str, query: str
|
|
77
|
+
) -> int:
|
|
78
|
+
"""Calculate comprehensive fuzzy score for an entity."""
|
|
79
|
+
score = 0
|
|
80
|
+
|
|
81
|
+
# Exact matches get highest scores
|
|
82
|
+
if query == entity_id.lower():
|
|
83
|
+
score += 100
|
|
84
|
+
elif query == friendly_name.lower():
|
|
85
|
+
score += 95
|
|
86
|
+
elif query == domain.lower():
|
|
87
|
+
score += 90
|
|
88
|
+
|
|
89
|
+
# Partial exact matches
|
|
90
|
+
if query in entity_id.lower():
|
|
91
|
+
score += 85
|
|
92
|
+
if query in friendly_name.lower():
|
|
93
|
+
score += 80
|
|
94
|
+
|
|
95
|
+
# Fuzzy matching scores
|
|
96
|
+
entity_id_ratio = calculate_ratio(query, entity_id.lower())
|
|
97
|
+
friendly_ratio = calculate_ratio(query, friendly_name.lower())
|
|
98
|
+
domain_ratio = calculate_ratio(query, domain.lower())
|
|
99
|
+
|
|
100
|
+
# Partial ratio for substring matching
|
|
101
|
+
entity_partial = calculate_partial_ratio(query, entity_id.lower())
|
|
102
|
+
friendly_partial = calculate_partial_ratio(query, friendly_name.lower())
|
|
103
|
+
|
|
104
|
+
# Token sort ratio for word order independence
|
|
105
|
+
entity_token = calculate_token_sort_ratio(query, entity_id.lower())
|
|
106
|
+
friendly_token = calculate_token_sort_ratio(query, friendly_name.lower())
|
|
107
|
+
|
|
108
|
+
# Weight the scores
|
|
109
|
+
score += max(entity_id_ratio, entity_partial, entity_token) * 0.7
|
|
110
|
+
score += max(friendly_ratio, friendly_partial, friendly_token) * 0.8
|
|
111
|
+
score += domain_ratio * 0.6
|
|
112
|
+
|
|
113
|
+
# Room/area keyword boosting
|
|
114
|
+
room_keywords = [
|
|
115
|
+
"salon",
|
|
116
|
+
"chambre",
|
|
117
|
+
"cuisine",
|
|
118
|
+
"salle",
|
|
119
|
+
"living",
|
|
120
|
+
"bedroom",
|
|
121
|
+
"kitchen",
|
|
122
|
+
]
|
|
123
|
+
for keyword in room_keywords:
|
|
124
|
+
if keyword in query and keyword in friendly_name.lower():
|
|
125
|
+
score += 15
|
|
126
|
+
|
|
127
|
+
# Device type boosting
|
|
128
|
+
device_keywords = [
|
|
129
|
+
"light",
|
|
130
|
+
"switch",
|
|
131
|
+
"sensor",
|
|
132
|
+
"climate",
|
|
133
|
+
"lumiere",
|
|
134
|
+
"interrupteur",
|
|
135
|
+
]
|
|
136
|
+
for keyword in device_keywords:
|
|
137
|
+
if keyword in query and (
|
|
138
|
+
keyword in domain or keyword in friendly_name.lower()
|
|
139
|
+
):
|
|
140
|
+
score += 10
|
|
141
|
+
|
|
142
|
+
return int(score)
|
|
143
|
+
|
|
144
|
+
def _get_match_type(
|
|
145
|
+
self, entity_id: str, friendly_name: str, domain: str, query: str
|
|
146
|
+
) -> str:
|
|
147
|
+
"""Determine the type of match for user feedback."""
|
|
148
|
+
if query == entity_id.lower():
|
|
149
|
+
return "exact_id"
|
|
150
|
+
elif query == friendly_name.lower():
|
|
151
|
+
return "exact_name"
|
|
152
|
+
elif query == domain.lower():
|
|
153
|
+
return "exact_domain"
|
|
154
|
+
elif query in entity_id.lower():
|
|
155
|
+
return "partial_id"
|
|
156
|
+
elif query in friendly_name.lower():
|
|
157
|
+
return "partial_name"
|
|
158
|
+
else:
|
|
159
|
+
return "fuzzy_match"
|
|
160
|
+
|
|
161
|
+
def search_by_area(
|
|
162
|
+
self, entities: list[dict[str, Any]], area_query: str
|
|
163
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
164
|
+
"""
|
|
165
|
+
Group entities by area/room based on fuzzy matching.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
entities: List of Home Assistant entity states
|
|
169
|
+
area_query: Area/room name to search for
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Dictionary with area matches grouped by inferred area
|
|
173
|
+
"""
|
|
174
|
+
area_matches: dict[str, list[dict[str, Any]]] = {}
|
|
175
|
+
area_lower = area_query.lower().strip()
|
|
176
|
+
|
|
177
|
+
for entity in entities:
|
|
178
|
+
entity_id = entity.get("entity_id", "")
|
|
179
|
+
attributes = entity.get("attributes", {})
|
|
180
|
+
friendly_name = attributes.get("friendly_name", entity_id)
|
|
181
|
+
|
|
182
|
+
# Check area_id attribute first
|
|
183
|
+
if "area_id" in attributes:
|
|
184
|
+
area_id = attributes["area_id"]
|
|
185
|
+
if area_lower in area_id.lower():
|
|
186
|
+
if area_id not in area_matches:
|
|
187
|
+
area_matches[area_id] = []
|
|
188
|
+
area_matches[area_id].append(entity)
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
# Fuzzy match on friendly name for room inference
|
|
192
|
+
area_score = calculate_partial_ratio(area_lower, friendly_name.lower())
|
|
193
|
+
if area_score >= self.threshold:
|
|
194
|
+
inferred_area = self._infer_area_from_name(friendly_name)
|
|
195
|
+
if inferred_area not in area_matches:
|
|
196
|
+
area_matches[inferred_area] = []
|
|
197
|
+
area_matches[inferred_area].append(entity)
|
|
198
|
+
|
|
199
|
+
return area_matches
|
|
200
|
+
|
|
201
|
+
def _infer_area_from_name(self, friendly_name: str) -> str:
|
|
202
|
+
"""Infer area/room from entity friendly name."""
|
|
203
|
+
name_lower = friendly_name.lower()
|
|
204
|
+
|
|
205
|
+
# Common French room names
|
|
206
|
+
french_rooms = {
|
|
207
|
+
"salon": "salon",
|
|
208
|
+
"chambre": "chambre",
|
|
209
|
+
"cuisine": "cuisine",
|
|
210
|
+
"salle": "salle_de_bain",
|
|
211
|
+
"bureau": "bureau",
|
|
212
|
+
"garage": "garage",
|
|
213
|
+
"jardin": "jardin",
|
|
214
|
+
"terrasse": "terrasse",
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# Common English room names
|
|
218
|
+
english_rooms = {
|
|
219
|
+
"living": "living_room",
|
|
220
|
+
"bedroom": "bedroom",
|
|
221
|
+
"kitchen": "kitchen",
|
|
222
|
+
"bathroom": "bathroom",
|
|
223
|
+
"office": "office",
|
|
224
|
+
"garage": "garage",
|
|
225
|
+
"garden": "garden",
|
|
226
|
+
"patio": "patio",
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
all_rooms = {**french_rooms, **english_rooms}
|
|
230
|
+
|
|
231
|
+
for keyword, room in all_rooms.items():
|
|
232
|
+
if keyword in name_lower:
|
|
233
|
+
return room
|
|
234
|
+
|
|
235
|
+
return "unknown_area"
|
|
236
|
+
|
|
237
|
+
def get_smart_suggestions(
|
|
238
|
+
self, entities: list[dict[str, Any]], query: str
|
|
239
|
+
) -> list[str]:
|
|
240
|
+
"""
|
|
241
|
+
Generate smart suggestions for failed searches.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
entities: List of Home Assistant entity states
|
|
245
|
+
query: Original search query
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
List of suggested search terms
|
|
249
|
+
"""
|
|
250
|
+
suggestions = []
|
|
251
|
+
|
|
252
|
+
# Extract unique domains
|
|
253
|
+
domains = set()
|
|
254
|
+
areas = set()
|
|
255
|
+
|
|
256
|
+
for entity in entities:
|
|
257
|
+
entity_id = entity.get("entity_id", "")
|
|
258
|
+
if "." in entity_id:
|
|
259
|
+
domains.add(entity_id.split(".")[0])
|
|
260
|
+
|
|
261
|
+
friendly_name = entity.get("attributes", {}).get("friendly_name", "")
|
|
262
|
+
inferred_area = self._infer_area_from_name(friendly_name)
|
|
263
|
+
if inferred_area != "unknown_area":
|
|
264
|
+
areas.add(inferred_area)
|
|
265
|
+
|
|
266
|
+
# Fuzzy match against domains
|
|
267
|
+
domain_matches = extract_best_matches(query, domains, limit=3)
|
|
268
|
+
suggestions.extend([match for match, score in domain_matches if score >= 60])
|
|
269
|
+
|
|
270
|
+
# Fuzzy match against areas
|
|
271
|
+
area_matches = extract_best_matches(query, areas, limit=3)
|
|
272
|
+
suggestions.extend([match for match, score in area_matches if score >= 60])
|
|
273
|
+
|
|
274
|
+
# Add common search patterns
|
|
275
|
+
if not suggestions:
|
|
276
|
+
suggestions.extend(
|
|
277
|
+
[
|
|
278
|
+
"light",
|
|
279
|
+
"switch",
|
|
280
|
+
"sensor",
|
|
281
|
+
"climate",
|
|
282
|
+
"salon",
|
|
283
|
+
"chambre",
|
|
284
|
+
"cuisine",
|
|
285
|
+
"living",
|
|
286
|
+
"bedroom",
|
|
287
|
+
"kitchen",
|
|
288
|
+
]
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return suggestions[:5]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def create_fuzzy_searcher(threshold: int = 60) -> FuzzyEntitySearcher:
|
|
295
|
+
"""Create a new fuzzy entity searcher instance."""
|
|
296
|
+
return FuzzyEntitySearcher(threshold)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def calculate_ratio(query: str, value: str) -> int:
|
|
300
|
+
"""Return the similarity ratio (0-100) using SequenceMatcher."""
|
|
301
|
+
return int(SequenceMatcher(None, query, value, autojunk=False).ratio() * 100)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def calculate_partial_ratio(query: str, value: str) -> int:
|
|
305
|
+
"""Return the best similarity score for any substring match."""
|
|
306
|
+
if not query or not value:
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
shorter, longer = (query, value) if len(query) <= len(value) else (value, query)
|
|
310
|
+
window = len(shorter)
|
|
311
|
+
if window == 0:
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
best_score = 0
|
|
315
|
+
for start in range(len(longer) - window + 1):
|
|
316
|
+
substring = longer[start : start + window]
|
|
317
|
+
best_score = max(best_score, calculate_ratio(shorter, substring))
|
|
318
|
+
if best_score == 100:
|
|
319
|
+
break
|
|
320
|
+
|
|
321
|
+
return best_score
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def calculate_token_sort_ratio(query: str, value: str) -> int:
|
|
325
|
+
"""Return similarity ratio after token sorting."""
|
|
326
|
+
query_sorted = " ".join(sorted(query.split()))
|
|
327
|
+
value_sorted = " ".join(sorted(value.split()))
|
|
328
|
+
return calculate_ratio(query_sorted, value_sorted)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def extract_best_matches(
|
|
332
|
+
query: str, choices: Iterable[str], limit: int = 3
|
|
333
|
+
) -> list[tuple[str, int]]:
|
|
334
|
+
"""Return the highest scoring matches for a query among choices."""
|
|
335
|
+
scored_choices = [
|
|
336
|
+
(choice, calculate_ratio(query, choice)) for choice in choices if choice
|
|
337
|
+
]
|
|
338
|
+
scored_choices.sort(key=lambda item: item[1], reverse=True)
|
|
339
|
+
return scored_choices[:limit]
|