ha-mcp-dev 7.1.0.dev297__tar.gz → 7.1.0.dev299__tar.gz

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 (97) hide show
  1. {ha_mcp_dev-7.1.0.dev297/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.1.0.dev299}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/smart_search.py +246 -51
  4. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_config_dashboards.py +341 -199
  5. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_integrations.py +90 -47
  6. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_search.py +152 -87
  7. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  8. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/LICENSE +0 -0
  9. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/MANIFEST.in +0 -0
  10. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/README.md +0 -0
  11. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/setup.cfg +0 -0
  12. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/__init__.py +0 -0
  13. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/__main__.py +0 -0
  14. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/_pypi_marker +0 -0
  15. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/auth/__init__.py +0 -0
  16. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/auth/consent_form.py +0 -0
  17. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/auth/provider.py +0 -0
  18. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/client/__init__.py +0 -0
  19. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/client/rest_client.py +0 -0
  20. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/client/websocket_client.py +0 -0
  21. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/client/websocket_listener.py +0 -0
  22. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/config.py +0 -0
  23. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/errors.py +0 -0
  24. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/py.typed +0 -0
  25. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/card_types.json +0 -0
  26. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/dashboard_guide.md +0 -0
  27. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  28. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  29. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  30. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  31. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  32. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  33. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  34. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  35. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  36. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  37. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  38. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  39. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  40. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  41. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  42. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  43. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/server.py +0 -0
  44. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/smoke_test.py +0 -0
  45. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/__init__.py +0 -0
  46. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/backup.py +0 -0
  47. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  48. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/device_control.py +0 -0
  49. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/enhanced.py +0 -0
  50. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/helpers.py +0 -0
  51. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/registry.py +0 -0
  52. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_addons.py +0 -0
  53. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_areas.py +0 -0
  54. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  55. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  56. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_calendar.py +0 -0
  57. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_camera.py +0 -0
  58. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  59. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  60. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  61. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_config_info.py +0 -0
  62. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  63. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_entities.py +0 -0
  64. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  65. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_groups.py +0 -0
  66. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_hacs.py +0 -0
  67. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_history.py +0 -0
  68. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_labels.py +0 -0
  69. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  70. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_registry.py +0 -0
  71. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_resources.py +0 -0
  72. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_service.py +0 -0
  73. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_services.py +0 -0
  74. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_system.py +0 -0
  75. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_todo.py +0 -0
  76. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_traces.py +0 -0
  77. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_updates.py +0 -0
  78. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_utility.py +0 -0
  79. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  80. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/tools_zones.py +0 -0
  81. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/tools/util_helpers.py +0 -0
  82. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/transforms/__init__.py +0 -0
  83. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/transforms/categorized_search.py +0 -0
  84. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/utils/__init__.py +0 -0
  85. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/utils/domain_handlers.py +0 -0
  86. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  87. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/utils/operation_manager.py +0 -0
  88. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/utils/python_sandbox.py +0 -0
  89. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp/utils/usage_logger.py +0 -0
  90. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  91. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  92. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  93. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  94. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  95. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/tests/__init__.py +0 -0
  96. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/tests/test_constants.py +0 -0
  97. {ha_mcp_dev-7.1.0.dev297 → ha_mcp_dev-7.1.0.dev299}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.1.0.dev297
3
+ Version: 7.1.0.dev299
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.1.0.dev297"
7
+ version = "7.1.0.dev299"
8
8
  description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.14"
@@ -24,7 +24,9 @@ BULK_WEBSOCKET_TIMEOUT = 3.0 # Timeout for bulk WebSocket calls
24
24
  INDIVIDUAL_CONFIG_TIMEOUT = 5.0 # Timeout for individual config fetches
25
25
 
26
26
  # Time budgets for fallback individual fetching (in seconds)
27
- AUTOMATION_CONFIG_TIME_BUDGET = 15.0 # Max time for fetching automation configs individually
27
+ AUTOMATION_CONFIG_TIME_BUDGET = (
28
+ 15.0 # Max time for fetching automation configs individually
29
+ )
28
30
  SCRIPT_CONFIG_TIME_BUDGET = 10.0 # Max time for fetching script configs individually
29
31
 
30
32
 
@@ -131,7 +133,9 @@ class SmartSearchTools:
131
133
  }
132
134
 
133
135
  if not matches or (matches and matches[0]["score"] < 80):
134
- response["suggestions"] = self.fuzzy_searcher.get_smart_suggestions(entities, query)
136
+ response["suggestions"] = self.fuzzy_searcher.get_smart_suggestions(
137
+ entities, query
138
+ )
135
139
 
136
140
  return response
137
141
 
@@ -144,7 +148,11 @@ class SmartSearchTools:
144
148
  "Verify entity exists with get_all_states",
145
149
  "Try simpler search terms",
146
150
  ],
147
- context={"query": query, "matches": [], "error_source": "smart_entity_search"},
151
+ context={
152
+ "query": query,
153
+ "matches": [],
154
+ "error_source": "smart_entity_search",
155
+ },
148
156
  )
149
157
 
150
158
  async def get_entities_by_area(
@@ -613,7 +621,11 @@ class SmartSearchTools:
613
621
  "Verify API token permissions",
614
622
  "Try test_connection first",
615
623
  ],
616
- context={"total_entities": 0, "entity_summary": {}, "controllable_devices": {}},
624
+ context={
625
+ "total_entities": 0,
626
+ "entity_summary": {},
627
+ "controllable_devices": {},
628
+ },
617
629
  )
618
630
 
619
631
  async def deep_search(
@@ -624,20 +636,22 @@ class SmartSearchTools:
624
636
  offset: int = 0,
625
637
  include_config: bool = False,
626
638
  concurrency_limit: int = DEFAULT_CONCURRENCY_LIMIT,
639
+ exact_match: bool = True,
627
640
  ) -> dict[str, Any]:
628
641
  """
629
- Deep search across automation, script, and helper definitions.
642
+ Deep search across automation, script, helper, and dashboard definitions.
630
643
 
631
644
  Searches not just entity names but also within configuration definitions
632
645
  including triggers, actions, sequences, and other config fields.
633
646
 
634
647
  Args:
635
- query: Search query (can be partial, with typos)
648
+ query: Search query (can be partial, with typos when exact_match=False)
636
649
  search_types: Types to search (default: ["automation", "script", "helper"])
637
650
  limit: Maximum total results to return (default: 5)
638
651
  offset: Number of results to skip for pagination (default: 0)
639
652
  include_config: Include full config in results (default: False)
640
653
  concurrency_limit: Max concurrent API calls for config fetching
654
+ exact_match: Use exact substring matching (default: True). Set False for fuzzy.
641
655
 
642
656
  Returns:
643
657
  Dictionary with search results grouped by type
@@ -650,6 +664,7 @@ class SmartSearchTools:
650
664
  "automations": [],
651
665
  "scripts": [],
652
666
  "helpers": [],
667
+ "dashboards": [],
653
668
  }
654
669
 
655
670
  query_lower = query.lower().strip()
@@ -738,7 +753,9 @@ class SmartSearchTools:
738
753
  all_automation_configs[uid] = item
739
754
  bulk_fetched = True
740
755
  except Exception as e:
741
- logger.debug(f"Automation WebSocket bulk fetch ({ws_type}) failed: {e}")
756
+ logger.debug(
757
+ f"Automation WebSocket bulk fetch ({ws_type}) failed: {e}"
758
+ )
742
759
 
743
760
  # Attempt C: Individual REST calls with time budget (LAST RESORT)
744
761
  # Prioritize name-matched automations so we at least get their configs
@@ -754,7 +771,10 @@ class SmartSearchTools:
754
771
  _name_score,
755
772
  unique_id,
756
773
  ) in sorted_by_score:
757
- if time.perf_counter() - budget_start > AUTOMATION_CONFIG_TIME_BUDGET:
774
+ if (
775
+ time.perf_counter() - budget_start
776
+ > AUTOMATION_CONFIG_TIME_BUDGET
777
+ ):
758
778
  break
759
779
  if not unique_id or unique_id in all_automation_configs:
760
780
  continue
@@ -767,7 +787,9 @@ class SmartSearchTools:
767
787
  )
768
788
  all_automation_configs[unique_id] = config
769
789
  except Exception as e:
770
- logger.debug(f"Automation individual config fetch ({unique_id}) failed: {e}")
790
+ logger.debug(
791
+ f"Automation individual config fetch ({unique_id}) failed: {e}"
792
+ )
771
793
 
772
794
  # Phase 3: Score with whatever configs we have
773
795
  for entity_id, friendly_name, name_score, unique_id in name_scored:
@@ -775,20 +797,27 @@ class SmartSearchTools:
775
797
  all_automation_configs.get(unique_id, {}) if unique_id else {}
776
798
  )
777
799
  config_match_score = (
778
- self._search_in_dict(config, query_lower) if config else 0
800
+ self._search_in_dict(config, query_lower, exact_match)
801
+ if config
802
+ else 0
803
+ )
804
+ total_score, threshold, match_in_name = self._score_deep_match(
805
+ entity_id,
806
+ friendly_name,
807
+ name_score,
808
+ config_match_score,
809
+ query_lower,
810
+ exact_match,
779
811
  )
780
- total_score = max(name_score, config_match_score)
781
812
 
782
- if total_score >= self.settings.fuzzy_threshold:
813
+ if total_score >= threshold:
783
814
  results["automations"].append(
784
815
  {
785
816
  "entity_id": entity_id,
786
817
  "friendly_name": friendly_name,
787
818
  "score": total_score,
788
- "match_in_name": name_score
789
- >= self.settings.fuzzy_threshold,
790
- "match_in_config": config_match_score
791
- >= self.settings.fuzzy_threshold,
819
+ "match_in_name": match_in_name,
820
+ "match_in_config": config_match_score >= threshold,
792
821
  "config": config if config else None,
793
822
  }
794
823
  )
@@ -861,7 +890,9 @@ class SmartSearchTools:
861
890
  all_script_configs[sid] = item
862
891
  script_bulk_fetched = True
863
892
  except Exception as e:
864
- logger.debug(f"Script WebSocket bulk fetch ({ws_type}) failed: {e}")
893
+ logger.debug(
894
+ f"Script WebSocket bulk fetch ({ws_type}) failed: {e}"
895
+ )
865
896
 
866
897
  # Attempt C: Individual fetch with budget
867
898
  if not script_bulk_fetched:
@@ -875,7 +906,10 @@ class SmartSearchTools:
875
906
  script_id,
876
907
  _name_score,
877
908
  ) in sorted_scripts:
878
- if time.perf_counter() - budget_start > SCRIPT_CONFIG_TIME_BUDGET:
909
+ if (
910
+ time.perf_counter() - budget_start
911
+ > SCRIPT_CONFIG_TIME_BUDGET
912
+ ):
879
913
  break
880
914
  if script_id in all_script_configs:
881
915
  continue
@@ -888,7 +922,9 @@ class SmartSearchTools:
888
922
  "config", {}
889
923
  )
890
924
  except Exception as e:
891
- logger.debug(f"Script individual config fetch ({script_id}) failed: {e}")
925
+ logger.debug(
926
+ f"Script individual config fetch ({script_id}) failed: {e}"
927
+ )
892
928
 
893
929
  # Phase 3: Score scripts
894
930
  for (
@@ -899,23 +935,28 @@ class SmartSearchTools:
899
935
  ) in script_name_scored:
900
936
  script_config = all_script_configs.get(script_id, {})
901
937
  config_match_score = (
902
- self._search_in_dict(script_config, query_lower)
938
+ self._search_in_dict(script_config, query_lower, exact_match)
903
939
  if script_config
904
940
  else 0
905
941
  )
906
- total_score = max(name_score, config_match_score)
942
+ total_score, threshold, match_in_name = self._score_deep_match(
943
+ entity_id,
944
+ friendly_name,
945
+ name_score,
946
+ config_match_score,
947
+ query_lower,
948
+ exact_match,
949
+ )
907
950
 
908
- if total_score >= self.settings.fuzzy_threshold:
951
+ if total_score >= threshold:
909
952
  results["scripts"].append(
910
953
  {
911
954
  "entity_id": entity_id,
912
955
  "script_id": script_id,
913
956
  "friendly_name": friendly_name,
914
957
  "score": total_score,
915
- "match_in_name": name_score
916
- >= self.settings.fuzzy_threshold,
917
- "match_in_config": config_match_score
918
- >= self.settings.fuzzy_threshold,
958
+ "match_in_name": match_in_name,
959
+ "match_in_config": config_match_score >= threshold,
919
960
  "config": script_config if script_config else None,
920
961
  }
921
962
  )
@@ -958,22 +999,29 @@ class SmartSearchTools:
958
999
  )
959
1000
  )
960
1001
  config_match_score = self._search_in_dict(
961
- helper, query_lower
1002
+ helper, query_lower, exact_match
1003
+ )
1004
+ total_score, threshold, match_in_name = (
1005
+ self._score_deep_match(
1006
+ entity_id,
1007
+ name,
1008
+ name_match_score,
1009
+ config_match_score,
1010
+ query_lower,
1011
+ exact_match,
1012
+ )
962
1013
  )
963
1014
 
964
- total_score = max(name_match_score, config_match_score)
965
-
966
- if total_score >= self.settings.fuzzy_threshold:
1015
+ if total_score >= threshold:
967
1016
  helper_results.append(
968
1017
  {
969
1018
  "entity_id": entity_id,
970
1019
  "helper_type": helper_type,
971
1020
  "name": name,
972
1021
  "score": total_score,
973
- "match_in_name": name_match_score
974
- >= self.settings.fuzzy_threshold,
1022
+ "match_in_name": match_in_name,
975
1023
  "match_in_config": config_match_score
976
- >= self.settings.fuzzy_threshold,
1024
+ >= threshold,
977
1025
  "config": helper,
978
1026
  }
979
1027
  )
@@ -996,6 +1044,98 @@ class SmartSearchTools:
996
1044
  elif isinstance(result, Exception):
997
1045
  logger.debug(f"Helper list fetch failed: {result}")
998
1046
 
1047
+ # ================================================================
1048
+ # DASHBOARD SEARCH
1049
+ # Fetches all storage-mode dashboards and the default dashboard,
1050
+ # then searches their configs (cards, badges, views) for the query.
1051
+ # ================================================================
1052
+ if "dashboard" in search_types:
1053
+ try:
1054
+ # List all storage-mode dashboards
1055
+ dash_list_resp = await self.client.send_websocket_message(
1056
+ {"type": "lovelace/dashboards/list"}
1057
+ )
1058
+ dashboard_entries: list[dict[str, Any]] = []
1059
+ if isinstance(dash_list_resp, dict) and dash_list_resp.get(
1060
+ "success"
1061
+ ):
1062
+ dashboard_entries = dash_list_resp.get("result", [])
1063
+
1064
+ # Build list of dashboards to search (include default)
1065
+ dashboards_to_search: list[tuple[str, str]] = [
1066
+ ("default", "Default Dashboard")
1067
+ ]
1068
+ for dash in dashboard_entries:
1069
+ url_path = dash.get("url_path", "")
1070
+ title = dash.get("title", url_path)
1071
+ if url_path:
1072
+ dashboards_to_search.append((url_path, title))
1073
+
1074
+ async def search_dashboard(
1075
+ url_path: str, title: str
1076
+ ) -> list[dict[str, Any]]:
1077
+ """Search a single dashboard's config for the query."""
1078
+ async with semaphore:
1079
+ try:
1080
+ get_data: dict[str, Any] = {"type": "lovelace/config"}
1081
+ if url_path != "default":
1082
+ get_data["url_path"] = url_path
1083
+ resp = await asyncio.wait_for(
1084
+ self.client.send_websocket_message(get_data),
1085
+ timeout=INDIVIDUAL_CONFIG_TIMEOUT,
1086
+ )
1087
+ config = (
1088
+ resp.get("result", resp)
1089
+ if isinstance(resp, dict)
1090
+ else resp
1091
+ )
1092
+ if not isinstance(config, dict):
1093
+ return []
1094
+
1095
+ # Search the entire dashboard config
1096
+ config_score = self._search_in_dict(
1097
+ config, query_lower, exact_match
1098
+ )
1099
+ threshold = (
1100
+ 100
1101
+ if exact_match
1102
+ else self.settings.fuzzy_threshold
1103
+ )
1104
+ if config_score >= threshold:
1105
+ return [
1106
+ {
1107
+ "dashboard_url": url_path,
1108
+ "dashboard_title": title,
1109
+ "score": config_score,
1110
+ "match_in_config": True,
1111
+ "config": config,
1112
+ }
1113
+ ]
1114
+ return []
1115
+ except Exception as e:
1116
+ logger.debug(
1117
+ f"Dashboard search failed ({url_path}): {e}"
1118
+ )
1119
+ return []
1120
+
1121
+ # Search all dashboards in parallel
1122
+ dash_results = await asyncio.gather(
1123
+ *[
1124
+ search_dashboard(url_path, title)
1125
+ for url_path, title in dashboards_to_search
1126
+ ],
1127
+ return_exceptions=True,
1128
+ )
1129
+ for dash_result in dash_results:
1130
+ if isinstance(dash_result, list):
1131
+ results["dashboards"].extend(dash_result)
1132
+ elif isinstance(dash_result, Exception):
1133
+ logger.debug(f"Dashboard search failed: {dash_result}")
1134
+
1135
+ except Exception as e:
1136
+ logger.error(f"Dashboard search error: {e}")
1137
+ raise
1138
+
999
1139
  # Merge all results with their category, sort by score, and paginate
1000
1140
  tagged_results: list[tuple[str, dict[str, Any]]] = []
1001
1141
  for category, items in results.items():
@@ -1004,13 +1144,14 @@ class SmartSearchTools:
1004
1144
  tagged_results.sort(key=lambda x: x[1]["score"], reverse=True)
1005
1145
 
1006
1146
  total_before_pagination = len(tagged_results)
1007
- paginated = tagged_results[offset:offset + limit]
1147
+ paginated = tagged_results[offset : offset + limit]
1008
1148
 
1009
1149
  # Re-group paginated results by category
1010
1150
  final_results: dict[str, list[dict[str, Any]]] = {
1011
1151
  "automations": [],
1012
1152
  "scripts": [],
1013
1153
  "helpers": [],
1154
+ "dashboards": [],
1014
1155
  }
1015
1156
  for category, item in paginated:
1016
1157
  if not include_config:
@@ -1019,7 +1160,7 @@ class SmartSearchTools:
1019
1160
 
1020
1161
  has_more = (offset + len(paginated)) < total_before_pagination
1021
1162
 
1022
- return {
1163
+ response: dict[str, Any] = {
1023
1164
  "success": True,
1024
1165
  "query": query,
1025
1166
  "total_matches": total_before_pagination,
@@ -1034,6 +1175,12 @@ class SmartSearchTools:
1034
1175
  "search_types": search_types,
1035
1176
  }
1036
1177
 
1178
+ # Only include dashboards key when dashboard search was requested
1179
+ if "dashboard" in search_types:
1180
+ response["dashboards"] = final_results["dashboards"]
1181
+
1182
+ return response
1183
+
1037
1184
  except Exception as e:
1038
1185
  logger.error(f"Error in deep_search: {e}")
1039
1186
  exception_to_structured_error(
@@ -1043,44 +1190,92 @@ class SmartSearchTools:
1043
1190
  "Verify automation/script/helper entities exist",
1044
1191
  "Try simpler search terms",
1045
1192
  ],
1046
- context={"query": query, "automations": [], "scripts": [], "helpers": []},
1193
+ context={
1194
+ "query": query,
1195
+ "automations": [],
1196
+ "scripts": [],
1197
+ "helpers": [],
1198
+ },
1047
1199
  )
1048
1200
 
1201
+ def _score_deep_match(
1202
+ self,
1203
+ entity_id: str,
1204
+ friendly_name: str,
1205
+ fuzzy_name_score: int,
1206
+ config_match_score: int,
1207
+ query_lower: str,
1208
+ exact_match: bool,
1209
+ ) -> tuple[int, int, bool]:
1210
+ """Compute total score, threshold, and match_in_name for a deep search result.
1211
+
1212
+ Returns (total_score, threshold, match_in_name).
1213
+ """
1214
+ if exact_match:
1215
+ name_exact = (
1216
+ 100
1217
+ if query_lower in entity_id.lower()
1218
+ or query_lower in friendly_name.lower()
1219
+ else 0
1220
+ )
1221
+ total_score = max(name_exact, config_match_score)
1222
+ return total_score, 100, name_exact >= 100
1223
+ else:
1224
+ total_score = max(fuzzy_name_score, config_match_score)
1225
+ threshold = self.settings.fuzzy_threshold
1226
+ return total_score, threshold, fuzzy_name_score >= threshold
1227
+
1049
1228
  def _search_in_dict(
1050
- self, data: dict[str, Any] | list[Any] | Any, query: str
1229
+ self,
1230
+ data: dict[str, Any] | list[Any] | Any,
1231
+ query: str,
1232
+ exact_match: bool = False,
1051
1233
  ) -> int:
1052
1234
  """
1053
1235
  Recursively search for query string in nested dictionary/list structures.
1054
1236
 
1055
- Returns a fuzzy match score based on how well the query matches values in the data.
1237
+ When exact_match is True, uses substring matching (returns 100 if found, 0 if not).
1238
+ When exact_match is False, uses fuzzy matching with partial ratio scoring.
1056
1239
  """
1057
1240
  max_score = 0
1058
1241
 
1059
1242
  if isinstance(data, dict):
1060
1243
  for key, value in data.items():
1061
- # Score the key itself
1062
- key_score = calculate_partial_ratio(query, str(key).lower())
1063
- max_score = max(max_score, key_score)
1244
+ if exact_match:
1245
+ if query in str(key).lower():
1246
+ return 100
1247
+ else:
1248
+ key_score = calculate_partial_ratio(query, str(key).lower())
1249
+ max_score = max(max_score, key_score)
1064
1250
 
1065
- # Recursively score the value
1066
- value_score = self._search_in_dict(value, query)
1251
+ value_score = self._search_in_dict(value, query, exact_match)
1067
1252
  max_score = max(max_score, value_score)
1253
+ if exact_match and max_score >= 100:
1254
+ return 100
1068
1255
 
1069
1256
  elif isinstance(data, list):
1070
1257
  for item in data:
1071
- item_score = self._search_in_dict(item, query)
1258
+ item_score = self._search_in_dict(item, query, exact_match)
1072
1259
  max_score = max(max_score, item_score)
1260
+ if exact_match and max_score >= 100:
1261
+ return 100
1073
1262
 
1074
1263
  elif isinstance(data, str):
1075
- # Direct fuzzy match on string values
1076
- max_score = max(max_score, calculate_partial_ratio(query, data.lower()))
1264
+ if exact_match:
1265
+ if query in data.lower():
1266
+ return 100
1267
+ else:
1268
+ max_score = max(max_score, calculate_partial_ratio(query, data.lower()))
1077
1269
 
1078
1270
  elif data is not None:
1079
- # Convert to string and match
1080
- max_score = max(
1081
- max_score,
1082
- calculate_partial_ratio(query, str(data).lower()),
1083
- )
1271
+ if exact_match:
1272
+ if query in str(data).lower():
1273
+ return 100
1274
+ else:
1275
+ max_score = max(
1276
+ max_score,
1277
+ calculate_partial_ratio(query, str(data).lower()),
1278
+ )
1084
1279
 
1085
1280
  return max_score
1086
1281