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