Skip to content

Commit 6ed6d3b

Browse files
kingpanther13claudelcrostarosa
authored
feat: add system/error logs, repairs, and ZHA radio metrics to existing tools (replaces #675) (#836)
* feat: consolidate diagnostic capabilities into existing tools Rename ha_get_logbook to ha_get_logs with source parameter supporting logbook (default), system (system_log/list), error_log (raw home-assistant.log), and supervisor (add-on container logs). Expand ha_get_system_health with include parameter for repairs (repairs/list_issues) and zha_network (zha/devices with LQI/RSSI). Add ZHA radio metrics (LQI/RSSI) auto-enrichment to ha_get_device for single ZHA device lookups. Add repair issue count and details to ha_get_overview. Zero new tools added — all capabilities integrated into existing tools. Closes #684 Closes #824 Inspired by #675 (@lcrostarosa) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve mypy errors, address review feedback, add e2e tests - Fix send_command() calls to pass string command type, not dict - Remove redundant None checks after coerce_int_param (Gemini review) - Replace bare except with logged warning for ZHA metrics (Gemini review) - Revert unintended ruff formatting changes in tools_registry.py and tools_system.py to keep diff focused on functional changes - Add e2e tests for new log sources (system, error_log, invalid source, invalid level, supervisor without slug, default source) - Add e2e tests for system health include parameter (repairs, default) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve mypy type errors and e2e test failures - Add assert for non-None after coerce_int_param (mypy needs narrowing since return type is int | None even with non-None default) - Fix validation error tests to use pytest.raises(ToolError) since raise_tool_error raises ToolError, not a dict response Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unused parse_mcp_result import to fix ruff lint Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback — Literal types, narrowed exceptions, search support, ZHA optimization - Add Literal type for ha_get_logs source param (exposes enum in JSON schema) - Add search keyword filtering to logbook source (was silently ignored) - Narrow all broad except Exception blocks to specific types per repo guidelines - Fix error_log level filtering: regex-based matching instead of naive substring - Optimize ZHA enrichment: O(1) dict lookup, stripped-down response fields - Add zha_network_full option for complete ZHA device data - Add coerce_int_param overloads to eliminate assert workarounds - Rename DEFAULT_LOGBOOK_LIMIT/MAX_LOGBOOK_LIMIT to generic names - Warn on source-incompatible parameter combinations - Improve supervisor log dict fallback with warning - Revert formatting-only changes in ha_eval_template - Add tests: search filter, error_log level, ZHA network, combined include, overview repair_count Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: handle ToolError in ZHA/combined system health E2E tests system_health WebSocket call raises ToolError in CI Docker containers where the component isn't available. Wrap call_tool in try/except and skip when system_health is unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: skip system health tests on any ToolError in CI The CI Docker container returns "Unknown command" for system_health/info, not "timeout" or "not available". Skip on any ToolError since the tool itself can't succeed without the system_health component. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use broad except for error-isolation blocks, proper test assertions The WebSocket client raises plain Exception for unknown commands (e.g. "zha/devices" when ZHA isn't installed). Section-level error isolation blocks (repairs, ZHA in system_health/overview/device) must catch Exception to gracefully degrade — narrowing to TimeoutError/OSError caused the whole tool to fail when any optional section's command was unknown. Revert tests to proper assertions (no blind skip) — with error isolation fixed, the tool returns success with error info in the section, so tests can actually verify the response structure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: credit @lcrostarosa in README contributors Co-Authored-By: Lucas Crostarosa <12022083+lcrostarosa@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Lucas Crostarosa <12022083+lcrostarosa@users.noreply.github.com>
1 parent c8afd28 commit 6ed6d3b

13 files changed

Lines changed: 1381 additions & 2995 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ Spend less time configuring, more time enjoying your smart home.
148148
| **Groups** | `ha_config_list_groups`, `ha_config_remove_group`, `ha_config_set_group` |
149149
| **HACS** | `ha_hacs_add_repository`, `ha_hacs_download`, `ha_hacs_info`, `ha_hacs_list_installed`, `ha_hacs_repository_info`, `ha_hacs_search` |
150150
| **Helper Entities** | `ha_config_list_helpers`, `ha_config_remove_helper`, `ha_config_set_helper`, `ha_get_helper_schema`, `ha_set_config_entry_helper` |
151-
| **History & Statistics** | `ha_get_automation_traces`, `ha_get_history`, `ha_get_logbook`, `ha_get_statistics` |
151+
| **History & Statistics** | `ha_get_automation_traces`, `ha_get_history`, `ha_get_logs`, `ha_get_statistics` |
152152
| **Integrations** | `ha_delete_config_entry`, `ha_get_integration`, `ha_set_integration_enabled` |
153153
| **Labels & Categories** | `ha_config_get_category`, `ha_config_get_label`, `ha_config_remove_category`, `ha_config_remove_label`, `ha_config_set_category`, `ha_config_set_label` |
154154
| **Scripts** | `ha_config_get_script`, `ha_config_remove_script`, `ha_config_set_script` |
@@ -253,6 +253,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
253253
- **[@smenzer](https://github.com/smenzer)** — Documentation fix.
254254
- **[@The-Greg-O](https://github.com/The-Greg-O)** — REST API for config entry deletion.
255255
- **[@restriction](https://github.com/restriction)** — Responsible disclosure: python_transform sandbox missing call target validation.
256+
- **[@lcrostarosa](https://github.com/lcrostarosa)** — Diagnostic and health monitoring tools concept (#675), inspiring system/error logs, repairs, and ZHA radio metrics integration.
256257

257258
---
258259

homeassistant-addon/DOCS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ The add-on provides 80+ MCP tools for controlling Home Assistant:
364364
- `ha_backup_restore` - Restore from backup
365365

366366
### Utility
367-
- `ha_get_logbook` - Historical events
367+
- `ha_get_logs` - Logs (logbook, system, error, supervisor)
368368
- `ha_eval_template` - Evaluate Jinja2 templates
369369
- Domain documentation available via the `ha_get_skill_home_assistant_best_practices` skill
370370
- `ha_get_integration` - List or get integration info

site/src/data/tools.json

Lines changed: 0 additions & 2879 deletions
This file was deleted.

src/ha_mcp/tools/tools_registry.py

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from fastmcp.exceptions import ToolError
1717
from pydantic import Field
1818

19+
from ..client.rest_client import HomeAssistantAPIError, HomeAssistantConnectionError
1920
from ..errors import ErrorCode, create_error_response
2021
from .helpers import (
2122
exception_to_structured_error,
@@ -563,11 +564,11 @@ async def ha_rename_entity(
563564
)
564565

565566
@mcp.tool(
566-
tags={"Device Registry"},
567+
tags={"Device Registry", "Zigbee", "Z-Wave"},
567568
annotations={
568569
"idempotentHint": True,
569570
"readOnlyHint": True,
570-
"title": "Get Device"
571+
"title": "Get Device (incl. Zigbee/ZHA/Z2M and Z-Wave)"
571572
}
572573
)
573574
@log_tool_usage
@@ -589,7 +590,7 @@ async def ha_get_device(
589590
integration: Annotated[
590591
str | None,
591592
Field(
592-
description="Filter devices by integration: 'zha', 'zigbee2mqtt', 'mqtt', 'hue', etc.",
593+
description="Filter devices by integration: 'zha', 'zigbee2mqtt', 'zwave_js', 'mqtt', 'hue', etc.",
593594
default=None,
594595
),
595596
] = None,
@@ -609,7 +610,7 @@ async def ha_get_device(
609610
] = None,
610611
) -> dict[str, Any]:
611612
"""
612-
Get device information - list all devices or get details for a specific one.
613+
Get device information, including Zigbee (ZHA/Z2M) and Z-Wave JS devices with protocol-specific details.
613614
614615
Without device_id/entity_id: Lists all devices with optional filters.
615616
With device_id or entity_id: Returns detailed info for that specific device.
@@ -625,15 +626,22 @@ async def ha_get_device(
625626
- By device_id: ha_get_device(device_id="abc123")
626627
- By entity_id: ha_get_device(entity_id="light.living_room")
627628
628-
**Zigbee automation tips:**
629+
**Zigbee device support:**
630+
- Use integration="zha" or integration="zigbee2mqtt" to list Zigbee devices
631+
- Returns ieee_address, integration_type, and radio metrics (LQI/RSSI) for ZHA devices
629632
- ZHA triggers: Use `ieee_address` for zha_event triggers
630633
- Z2M triggers: Use `friendly_name` for MQTT topics (zigbee2mqtt/{friendly_name}/action)
631634
635+
**Z-Wave JS device support:**
636+
- Use integration="zwave_js" to list Z-Wave devices
637+
- Returns node_id extracted from device identifiers
638+
- Single device lookup includes node_status (security class, routing, Z-Wave+ version)
639+
632640
**Returns (list mode):**
633641
- List of devices with device_id, name, manufacturer, model, area_id
634642
635643
**Returns (single device):**
636-
- Full device details including integration_type, ieee_address, entities
644+
- Full device details including integration_type, ieee_address/node_id, radio_metrics/node_status, entities
637645
"""
638646
try:
639647
# Get device registry
@@ -695,9 +703,10 @@ def get_device_info(device: dict[str, Any]) -> dict[str, Any]:
695703
identifiers = device.get("identifiers", [])
696704
connections = device.get("connections", [])
697705

698-
# Determine integration type and extract IEEE address
706+
# Determine integration type and extract IEEE/node addresses
699707
integration_sources = []
700708
ieee_address = None
709+
zwave_node_id = None
701710
friendly_name = device.get("name_by_user") or device.get("name")
702711
is_z2m = False
703712

@@ -719,6 +728,10 @@ def get_device_info(device: dict[str, Any]) -> dict[str, Any]:
719728
if "_0x" in value:
720729
ieee_address = "0x" + value.split("_0x")[-1]
721730

731+
# Z-Wave JS: identifier is ["zwave_js", "{home_id}-{node_id}"]
732+
if domain == "zwave_js" and "-" in value:
733+
zwave_node_id = value.split("-")[1]
734+
722735
# Also check connections for IEEE
723736
for connection in connections:
724737
if isinstance(connection, (list, tuple)) and len(connection) >= 2:
@@ -730,6 +743,8 @@ def get_device_info(device: dict[str, Any]) -> dict[str, Any]:
730743
integration_type = "zha"
731744
elif is_z2m:
732745
integration_type = "zigbee2mqtt"
746+
elif "zwave_js" in integration_sources:
747+
integration_type = "zwave_js"
733748
elif "mqtt" in integration_sources:
734749
integration_type = "mqtt"
735750
elif integration_sources:
@@ -762,6 +777,10 @@ def get_device_info(device: dict[str, Any]) -> dict[str, Any]:
762777
f"Use ieee '{ieee_address}' for zha_event triggers"
763778
)
764779

780+
# Add Z-Wave specific info
781+
if integration_type == "zwave_js" and zwave_node_id:
782+
device_info["node_id"] = zwave_node_id
783+
765784
return device_info
766785

767786
# Single device lookup mode
@@ -795,6 +814,67 @@ def get_device_info(device: dict[str, Any]) -> dict[str, Any]:
795814
device_info["connections"] = device.get("connections", [])
796815
device_info["identifiers"] = device.get("identifiers", [])
797816

817+
# Enrich ZHA devices with radio metrics (LQI/RSSI)
818+
if device_info.get("integration_type") == "zha" and device_info.get(
819+
"ieee_address"
820+
):
821+
try:
822+
zha_result = await client.send_websocket_message(
823+
{"type": "zha/devices"}
824+
)
825+
if zha_result.get("success"):
826+
# Build ieee→metrics map for O(1) lookup
827+
zha_by_ieee = {
828+
d.get("ieee"): d
829+
for d in zha_result.get("result", [])
830+
if d.get("ieee")
831+
}
832+
target_ieee = device_info["ieee_address"]
833+
zha_dev = zha_by_ieee.get(target_ieee)
834+
if zha_dev:
835+
device_info["radio_metrics"] = {
836+
"lqi": zha_dev.get("lqi"),
837+
"rssi": zha_dev.get("rssi"),
838+
}
839+
except (HomeAssistantConnectionError, HomeAssistantAPIError, TimeoutError, OSError) as e:
840+
logger.warning(
841+
"Could not fetch ZHA radio metrics for device %s: %s",
842+
device_info.get("device_id"),
843+
e,
844+
)
845+
846+
# Enrich Z-Wave JS devices with node status
847+
if device_info.get("integration_type") == "zwave_js" and device_info.get(
848+
"node_id"
849+
):
850+
try:
851+
zwave_result = await client.send_websocket_message(
852+
{"type": "zwave_js/node_status", "device_id": device_id}
853+
)
854+
if zwave_result.get("success"):
855+
node_data = zwave_result.get("result", {})
856+
device_info["node_status"] = {
857+
"node_id": node_data.get("node_id"),
858+
"status": node_data.get("status"),
859+
"is_routing": node_data.get("is_routing"),
860+
"is_secure": node_data.get("is_secure"),
861+
"highest_security_class": node_data.get(
862+
"highest_security_class"
863+
),
864+
"zwave_plus_version": node_data.get(
865+
"zwave_plus_version"
866+
),
867+
"is_controller_node": node_data.get(
868+
"is_controller_node"
869+
),
870+
}
871+
except (HomeAssistantConnectionError, HomeAssistantAPIError, TimeoutError, OSError) as e:
872+
logger.warning(
873+
"Could not fetch Z-Wave node status for device %s: %s",
874+
device_info.get("device_id"),
875+
e,
876+
)
877+
798878
entities = device_info.get("entities", [])
799879
return {
800880
"success": True,
@@ -825,21 +905,15 @@ def get_device_info(device: dict[str, Any]) -> dict[str, Any]:
825905

826906
# Apply integration filter if specified
827907
if integration_lower:
828-
# Match integration
829-
if (
830-
(
831-
integration_lower == "zigbee2mqtt"
832-
and device_info["integration_type"] != "zigbee2mqtt"
833-
)
834-
or (
835-
integration_lower == "zha"
836-
and device_info["integration_type"] != "zha"
837-
)
838-
or (
839-
integration_lower not in ["zigbee2mqtt", "zha"]
840-
and integration_lower
841-
not in device_info.get("integration_sources", [])
842-
)
908+
# Match integration — named types get exact match,
909+
# others match against integration_sources list
910+
named_types = ["zigbee2mqtt", "zha", "zwave_js"]
911+
if integration_lower in named_types:
912+
if device_info["integration_type"] != integration_lower:
913+
continue
914+
elif (
915+
integration_lower
916+
not in device_info.get("integration_sources", [])
843917
):
844918
continue
845919

@@ -892,6 +966,11 @@ def get_device_info(device: dict[str, Any]) -> dict[str, Any]:
892966
result["usage_hint"] = (
893967
"Use 'ieee_address' for zha_event triggers in automations"
894968
)
969+
elif integration_lower == "zwave_js":
970+
result["usage_hint"] = (
971+
"Use node_id for Z-Wave device identification. "
972+
"Single device lookup includes node status (security, routing)."
973+
)
895974

896975
return result
897976

src/ha_mcp/tools/tools_search.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,29 @@ async def ha_get_overview(
665665
except Exception as e:
666666
logger.warning(f"Failed to fetch notifications for overview: {e}")
667667

668+
# Include active repair issues
669+
result["repair_count"] = 0
670+
try:
671+
repairs_result = await client.send_websocket_message(
672+
{"type": "repairs/list_issues"}
673+
)
674+
if repairs_result.get("success"):
675+
issues = repairs_result.get("result", {}).get("issues", [])
676+
result["repair_count"] = len(issues)
677+
if issues:
678+
result["repairs"] = [
679+
{
680+
"issue_id": r.get("issue_id"),
681+
"domain": r.get("domain"),
682+
"severity": r.get("severity"),
683+
"translation_key": r.get("translation_key"),
684+
}
685+
for r in issues
686+
]
687+
except Exception as e:
688+
logger.warning("Failed to fetch repairs for overview: %s", e)
689+
result["repairs_error"] = f"Could not fetch repairs: {e}"
690+
668691
# Include tool discovery hint when search transform is active
669692
settings = get_global_settings()
670693
if settings.enable_tool_search:

0 commit comments

Comments
 (0)