Skip to content

Commit a76c6e2

Browse files
fix: validate operations in ha_bulk_control and report errors (#385) (#473)
* fix: validate operations in ha_bulk_control and report errors (#385) Root cause: Silent `continue` statements in bulk_device_control skipped operations missing entity_id or action without logging or reporting errors. This caused empty operation lists when all operations had malformed data. Changes: - Add validate_operation() helper to check each operation - Track skipped operations with index, original data, and error message - Log warnings for each skipped operation - Include skipped_operations count and skipped_details in response - Add suggestions when operations are skipped - Handle non-dict operations gracefully The response now includes: - skipped_operations: count of skipped operations - skipped_details: list with index, error, and original operation - suggestions: helpful messages for fixing malformed operations * refactor: centralize validation to eliminate duplication per review --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99cc81c commit a76c6e2

2 files changed

Lines changed: 234 additions & 30 deletions

File tree

src/ha_mcp/tools/device_control.py

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -486,44 +486,93 @@ async def bulk_device_control(
486486
if not operations:
487487
return {"success": False, "error": "No operations provided", "results": []}
488488

489-
results = []
490-
operation_ids = []
489+
results: list[dict[str, Any]] = []
490+
operation_ids: list[str] = []
491+
skipped_operations: list[dict[str, Any]] = []
492+
493+
def validate_operation(
494+
op: Any, index: int
495+
) -> tuple[str | None, str | None, str | None]:
496+
"""Validate operation and return (entity_id, action, error) tuple."""
497+
if not isinstance(op, dict):
498+
error = f"Operation at index {index} is not a dict: {type(op).__name__}"
499+
logger.warning(f"Bulk control: {error}")
500+
return None, None, error
501+
502+
entity_id = op.get("entity_id")
503+
action = op.get("action")
504+
505+
missing_fields = []
506+
if not entity_id:
507+
missing_fields.append("entity_id")
508+
if not action:
509+
missing_fields.append("action")
510+
511+
if missing_fields:
512+
error = (
513+
f"Operation at index {index} missing required fields: "
514+
f"{', '.join(missing_fields)}"
515+
)
516+
logger.warning(f"Bulk control: {error}")
517+
return None, None, error
518+
519+
return str(entity_id), str(action), None
491520

492521
try:
522+
# Validate all operations first (centralized validation)
523+
valid_operations: list[tuple[int, dict[str, Any], str, str]] = []
524+
for i, op in enumerate(operations):
525+
entity_id, action, error = validate_operation(op, i)
526+
if error:
527+
skipped_operations.append(
528+
{
529+
"index": i,
530+
"operation": op,
531+
"error": error,
532+
"success": False,
533+
}
534+
)
535+
else:
536+
# Store (index, original_op, entity_id, action) for execution
537+
valid_operations.append((i, op, entity_id, action)) # type: ignore[arg-type]
538+
539+
# Execute only valid operations
493540
if parallel:
494-
# Execute all operations in parallel
541+
# Build tasks for parallel execution
495542
tasks = []
496-
for op in operations:
497-
entity_id = op.get("entity_id")
498-
action = op.get("action")
499-
if not entity_id or not action:
500-
continue
543+
for _i, op, entity_id, action in valid_operations:
501544
task = self.control_device_smart(
502-
entity_id=str(entity_id),
503-
action=str(action),
545+
entity_id=entity_id,
546+
action=action,
504547
parameters=op.get("parameters"),
505548
timeout_seconds=op.get("timeout_seconds", 10),
506549
validate_first=op.get("validate_first", True),
507550
)
508551
tasks.append(task)
509552

510-
results = await asyncio.gather(*tasks, return_exceptions=True)
511-
512-
# Extract operation IDs
513-
for result in results:
514-
if isinstance(result, dict) and "operation_id" in result:
515-
operation_ids.append(result["operation_id"])
553+
if tasks:
554+
task_results = await asyncio.gather(*tasks, return_exceptions=True)
555+
556+
# Process results and extract operation IDs
557+
for result in task_results:
558+
if isinstance(result, Exception):
559+
results.append(
560+
{
561+
"success": False,
562+
"error": f"Exception during execution: {result!s}",
563+
}
564+
)
565+
elif isinstance(result, dict):
566+
results.append(result)
567+
if "operation_id" in result:
568+
operation_ids.append(result["operation_id"])
516569

517570
else:
518-
# Execute operations sequentially
519-
for op in operations:
520-
entity_id = op.get("entity_id")
521-
action = op.get("action")
522-
if not entity_id or not action:
523-
continue
571+
# Execute valid operations sequentially
572+
for _i, op, entity_id, action in valid_operations:
524573
result = await self.control_device_smart(
525-
entity_id=str(entity_id),
526-
action=str(action),
574+
entity_id=entity_id,
575+
action=action,
527576
parameters=op.get("parameters"),
528577
timeout_seconds=op.get("timeout_seconds", 10),
529578
validate_first=op.get("validate_first", True),
@@ -533,34 +582,52 @@ async def bulk_device_control(
533582
if "operation_id" in result:
534583
operation_ids.append(result["operation_id"])
535584

536-
# Count successes and failures
585+
# Count successes and failures from executed operations
537586
successful = len(
538587
[r for r in results if isinstance(r, dict) and r.get("command_sent")]
539588
)
540-
failed = len(results) - successful
589+
executed_failed = len(results) - successful
590+
# Total failed includes both execution failures and skipped operations
591+
total_failed = executed_failed + len(skipped_operations)
541592

542-
return {
593+
response: dict[str, Any] = {
543594
"total_operations": len(operations),
544595
"successful_commands": successful,
545-
"failed_commands": failed,
596+
"failed_commands": total_failed,
597+
"skipped_operations": len(skipped_operations),
546598
"execution_mode": "parallel" if parallel else "sequential",
547599
"operation_ids": operation_ids,
548600
"results": results,
549601
"follow_up": (
550602
{
551-
"message": f"Use get_bulk_operation_status() to check all {len(operation_ids)} operations",
603+
"message": (
604+
f"Use get_bulk_operation_status() to check all "
605+
f"{len(operation_ids)} operations"
606+
),
552607
"operation_ids": operation_ids,
553608
}
554609
if operation_ids
555610
else None
556611
),
557612
}
558613

614+
# Include skipped operation details if any were skipped
615+
if skipped_operations:
616+
response["skipped_details"] = skipped_operations
617+
response["suggestions"] = [
618+
"Some operations were skipped due to validation errors",
619+
"Each operation requires 'entity_id' and 'action' fields",
620+
"Check skipped_details for specific errors",
621+
"Example format: {'entity_id': 'light.living_room', 'action': 'on'}",
622+
]
623+
624+
return response
625+
559626
except Exception as e:
560627
logger.error(f"Error in bulk_device_control: {e}")
561628
return {
562629
"success": False,
563-
"error": f"Bulk operation failed: {str(e)}",
630+
"error": f"Bulk operation failed: {e!s}",
564631
"results": results,
565632
}
566633

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Unit tests for bulk_device_control validation in device_control module."""
2+
3+
import pytest
4+
5+
from ha_mcp.tools.device_control import DeviceControlTools
6+
7+
8+
class TestBulkDeviceControlValidation:
9+
"""Test bulk_device_control validation logic."""
10+
11+
@pytest.fixture
12+
def device_control_tools(self):
13+
"""Create DeviceControlTools with mocked client."""
14+
# Pass None client - we won't actually make calls for validation tests
15+
return DeviceControlTools(client=None)
16+
17+
@pytest.mark.asyncio
18+
async def test_empty_operations_returns_error(self, device_control_tools):
19+
"""Empty operations list returns error."""
20+
result = await device_control_tools.bulk_device_control([])
21+
assert result["success"] is False
22+
assert "No operations provided" in result["error"]
23+
24+
@pytest.mark.asyncio
25+
async def test_missing_entity_id_reports_error(self, device_control_tools):
26+
"""Operations missing entity_id are reported in skipped_operations."""
27+
operations = [
28+
{"action": "on"}, # Missing entity_id
29+
]
30+
result = await device_control_tools.bulk_device_control(operations)
31+
32+
assert result["total_operations"] == 1
33+
assert result["skipped_operations"] == 1
34+
assert len(result["skipped_details"]) == 1
35+
assert "entity_id" in result["skipped_details"][0]["error"]
36+
assert result["skipped_details"][0]["index"] == 0
37+
38+
@pytest.mark.asyncio
39+
async def test_missing_action_reports_error(self, device_control_tools):
40+
"""Operations missing action are reported in skipped_operations."""
41+
operations = [
42+
{"entity_id": "light.test"}, # Missing action
43+
]
44+
result = await device_control_tools.bulk_device_control(operations)
45+
46+
assert result["total_operations"] == 1
47+
assert result["skipped_operations"] == 1
48+
assert len(result["skipped_details"]) == 1
49+
assert "action" in result["skipped_details"][0]["error"]
50+
51+
@pytest.mark.asyncio
52+
async def test_missing_both_fields_reports_both(self, device_control_tools):
53+
"""Operations missing both fields report both missing fields."""
54+
operations = [
55+
{}, # Missing both entity_id and action
56+
]
57+
result = await device_control_tools.bulk_device_control(operations)
58+
59+
assert result["skipped_operations"] == 1
60+
error_msg = result["skipped_details"][0]["error"]
61+
assert "entity_id" in error_msg
62+
assert "action" in error_msg
63+
64+
@pytest.mark.asyncio
65+
async def test_non_dict_operation_reports_error(self, device_control_tools):
66+
"""Non-dict operations are reported as errors."""
67+
operations = [
68+
"not a dict",
69+
123,
70+
None,
71+
]
72+
result = await device_control_tools.bulk_device_control(operations)
73+
74+
assert result["total_operations"] == 3
75+
assert result["skipped_operations"] == 3
76+
assert len(result["skipped_details"]) == 3
77+
for detail in result["skipped_details"]:
78+
assert "not a dict" in detail["error"]
79+
80+
@pytest.mark.asyncio
81+
async def test_mixed_valid_and_invalid_operations(self, device_control_tools):
82+
"""Mix of valid and invalid operations reports skipped ones.
83+
84+
Note: This test only validates that invalid operations are tracked.
85+
Valid operations would require a real HA connection to execute.
86+
"""
87+
operations = [
88+
{"entity_id": "light.test", "action": "on"}, # Valid (but will fail without HA)
89+
{"action": "off"}, # Invalid - missing entity_id
90+
{"entity_id": "switch.test"}, # Invalid - missing action
91+
]
92+
result = await device_control_tools.bulk_device_control(operations)
93+
94+
assert result["total_operations"] == 3
95+
assert result["skipped_operations"] == 2
96+
# The valid operation would be attempted but fail (no client)
97+
# so we check that skipped operations are properly tracked
98+
assert len(result["skipped_details"]) == 2
99+
100+
# Verify indices are tracked correctly
101+
skipped_indices = [d["index"] for d in result["skipped_details"]]
102+
assert 1 in skipped_indices # Missing entity_id
103+
assert 2 in skipped_indices # Missing action
104+
105+
@pytest.mark.asyncio
106+
async def test_all_invalid_operations_has_suggestions(self, device_control_tools):
107+
"""When operations are skipped, response includes suggestions."""
108+
operations = [
109+
{"action": "on"}, # Invalid
110+
]
111+
result = await device_control_tools.bulk_device_control(operations)
112+
113+
assert "suggestions" in result
114+
assert any("entity_id" in s for s in result["suggestions"])
115+
assert any("action" in s for s in result["suggestions"])
116+
117+
@pytest.mark.asyncio
118+
async def test_skipped_details_includes_original_operation(self, device_control_tools):
119+
"""Skipped details include the original operation for debugging."""
120+
original_op = {"action": "on", "parameters": {"brightness": 100}}
121+
operations = [original_op]
122+
result = await device_control_tools.bulk_device_control(operations)
123+
124+
assert result["skipped_details"][0]["operation"] == original_op
125+
126+
@pytest.mark.asyncio
127+
async def test_sequential_execution_validates_operations(self, device_control_tools):
128+
"""Sequential execution mode also validates operations."""
129+
operations = [
130+
{"action": "on"}, # Missing entity_id
131+
]
132+
result = await device_control_tools.bulk_device_control(
133+
operations, parallel=False
134+
)
135+
136+
assert result["skipped_operations"] == 1
137+
assert result["execution_mode"] == "sequential"

0 commit comments

Comments
 (0)