Skip to content

Commit 9e441db

Browse files
airlabnoclaudejulienld
authored
Support 'data' field in schedule time blocks (#578)
* Support 'data' field in schedule time blocks The schedule helper's time block type annotation used `dict[str, str]` which rejected the nested `data` dict that Home Assistant's schedule integration supports for additional attributes (e.g. mode, brightness). Additionally, the formatting loop only copied 'from' and 'to' keys, silently dropping any 'data' field even if it had passed validation. Changes: - Change type from `list[dict[str, str]]` to `list[dict[str, Any]]` - Pass through the optional 'data' dict in the formatting loop - Update field descriptions and docstring with data field examples Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add E2E test for schedule data field Adds test_schedule_with_data_field that verifies: - Schedule creation with 'data' dict on time blocks - Data field is preserved in creation response - Mode attribute is exposed when schedule block is active Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Julien Larocque-Dupont <github@qc-h.net>
1 parent a553d69 commit 9e441db

2 files changed

Lines changed: 106 additions & 14 deletions

File tree

src/ha_mcp/tools/tools_config_helpers.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -245,51 +245,51 @@ async def ha_config_set_helper(
245245
),
246246
] = None,
247247
monday: Annotated[
248-
list[dict[str, str]] | None,
248+
list[dict[str, Any]] | None,
249249
Field(
250-
description="Schedule time ranges for Monday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
250+
description="Schedule time ranges for Monday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts. Optional 'data' dict for additional attributes (e.g. {'from': '07:00', 'to': '22:00', 'data': {'mode': 'comfort'}})",
251251
default=None,
252252
),
253253
] = None,
254254
tuesday: Annotated[
255-
list[dict[str, str]] | None,
255+
list[dict[str, Any]] | None,
256256
Field(
257-
description="Schedule time ranges for Tuesday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
257+
description="Schedule time ranges for Tuesday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts. Optional 'data' dict for additional attributes.",
258258
default=None,
259259
),
260260
] = None,
261261
wednesday: Annotated[
262-
list[dict[str, str]] | None,
262+
list[dict[str, Any]] | None,
263263
Field(
264-
description="Schedule time ranges for Wednesday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
264+
description="Schedule time ranges for Wednesday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts. Optional 'data' dict for additional attributes.",
265265
default=None,
266266
),
267267
] = None,
268268
thursday: Annotated[
269-
list[dict[str, str]] | None,
269+
list[dict[str, Any]] | None,
270270
Field(
271-
description="Schedule time ranges for Thursday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
271+
description="Schedule time ranges for Thursday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts. Optional 'data' dict for additional attributes.",
272272
default=None,
273273
),
274274
] = None,
275275
friday: Annotated[
276-
list[dict[str, str]] | None,
276+
list[dict[str, Any]] | None,
277277
Field(
278-
description="Schedule time ranges for Friday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
278+
description="Schedule time ranges for Friday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts. Optional 'data' dict for additional attributes.",
279279
default=None,
280280
),
281281
] = None,
282282
saturday: Annotated[
283-
list[dict[str, str]] | None,
283+
list[dict[str, Any]] | None,
284284
Field(
285-
description="Schedule time ranges for Saturday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
285+
description="Schedule time ranges for Saturday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts. Optional 'data' dict for additional attributes.",
286286
default=None,
287287
),
288288
] = None,
289289
sunday: Annotated[
290-
list[dict[str, str]] | None,
290+
list[dict[str, Any]] | None,
291291
Field(
292-
description="Schedule time ranges for Sunday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts",
292+
description="Schedule time ranges for Sunday. List of {'from': 'HH:MM', 'to': 'HH:MM'} dicts. Optional 'data' dict for additional attributes.",
293293
default=None,
294294
),
295295
] = None,
@@ -369,6 +369,7 @@ async def ha_config_set_helper(
369369
- ha_config_set_helper("timer", "Laundry", duration="0:45:00")
370370
- ha_config_set_helper("zone", "Office", latitude=37.77, longitude=-122.41, radius=100)
371371
- ha_config_set_helper("schedule", "Work", monday=[{"from": "09:00", "to": "17:00"}])
372+
- ha_config_set_helper("schedule", "Light", monday=[{"from": "07:00", "to": "22:00", "data": {"brightness": "100", "mode": "comfort"}}])
372373
373374
PREFER BUILT-IN HELPERS OVER TEMPLATE SENSORS:
374375
Before creating a template sensor, check if a built-in helper/integration exists:
@@ -523,6 +524,7 @@ async def ha_config_set_helper(
523524
elif helper_type == "schedule":
524525
# Schedule parameters: monday-sunday with time ranges
525526
# Each day is a list of {"from": "HH:MM:SS", "to": "HH:MM:SS"}
527+
# with optional "data" dict for additional attributes
526528
day_params = {
527529
"monday": monday,
528530
"tuesday": tuesday,
@@ -545,6 +547,10 @@ async def ha_config_set_helper(
545547
if time_val.count(":") == 1:
546548
time_val = f"{time_val}:00"
547549
formatted_range[key] = time_val
550+
# Pass through the optional 'data' dict
551+
# for additional attributes (e.g. mode, brightness)
552+
if "data" in time_range:
553+
formatted_range["data"] = time_range["data"]
548554
formatted_ranges.append(formatted_range)
549555
message[day_name] = formatted_ranges
550556

tests/src/e2e/workflows/config/test_helper_crud.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,92 @@ async def check_schedule_exists():
979979
)
980980
logger.info("Schedule cleanup complete")
981981

982+
async def test_schedule_with_data_field(self, mcp_client, cleanup_tracker):
983+
"""Test creating a schedule with additional data attributes on time blocks."""
984+
logger.info("Testing schedule with data field on time blocks")
985+
986+
helper_name = "E2E Test Schedule Data"
987+
988+
# CREATE schedule with 'data' field on time blocks
989+
create_result = await mcp_client.call_tool(
990+
"ha_config_set_helper",
991+
{
992+
"helper_type": "schedule",
993+
"name": helper_name,
994+
"icon": "mdi:calendar-clock",
995+
"monday": [
996+
{"from": "07:00", "to": "22:00", "data": {"mode": "comfort"}},
997+
{"from": "22:00", "to": "23:59", "data": {"mode": "sleep"}},
998+
],
999+
"tuesday": [
1000+
{"from": "07:00", "to": "22:00", "data": {"mode": "comfort"}},
1001+
],
1002+
},
1003+
)
1004+
1005+
create_data = assert_mcp_success(create_result, "Create schedule with data")
1006+
entity_id = get_entity_id_from_response(create_data, "schedule")
1007+
assert entity_id, f"Missing entity_id: {create_data}"
1008+
cleanup_tracker.track("schedule", entity_id)
1009+
logger.info(f"Created schedule with data: {entity_id}")
1010+
1011+
# Verify the helper_data includes the data field in time blocks
1012+
helper_data = create_data.get("helper_data", {})
1013+
monday_blocks = helper_data.get("monday", [])
1014+
assert len(monday_blocks) == 2, f"Expected 2 Monday blocks, got {len(monday_blocks)}"
1015+
1016+
# Check that data field is preserved in the response
1017+
first_block = monday_blocks[0]
1018+
assert "data" in first_block, f"Missing 'data' in first block: {first_block}"
1019+
assert first_block["data"].get("mode") == "comfort", (
1020+
f"Expected mode='comfort', got: {first_block['data']}"
1021+
)
1022+
1023+
second_block = monday_blocks[1]
1024+
assert "data" in second_block, f"Missing 'data' in second block: {second_block}"
1025+
assert second_block["data"].get("mode") == "sleep", (
1026+
f"Expected mode='sleep', got: {second_block['data']}"
1027+
)
1028+
logger.info("Schedule data field verified in creation response")
1029+
1030+
# Wait for entity to be registered
1031+
async def check_schedule_exists():
1032+
result = await mcp_client.call_tool("ha_get_state", {"entity_id": entity_id})
1033+
data = parse_mcp_result(result)
1034+
if 'data' in data and data['data'] is not None:
1035+
state = data.get("data", {}).get("state")
1036+
return state in ["on", "off"]
1037+
return False
1038+
1039+
state_reached = await wait_for_condition(
1040+
check_schedule_exists, timeout=10, condition_name=f"schedule {entity_id} registration"
1041+
)
1042+
assert state_reached, f"Schedule {entity_id} not registered within timeout"
1043+
1044+
# If schedule is currently active (on), verify data attributes are exposed
1045+
state_result = await mcp_client.call_tool(
1046+
"ha_get_state",
1047+
{"entity_id": entity_id},
1048+
)
1049+
state_data = parse_mcp_result(state_result)
1050+
if 'data' in state_data and state_data['data'] is not None:
1051+
entity_state = state_data["data"].get("state")
1052+
attrs = state_data["data"].get("attributes", {})
1053+
logger.info(f"Schedule state: {entity_state}, attributes: {attrs}")
1054+
if entity_state == "on":
1055+
# When active, the 'mode' from data should be an attribute
1056+
assert "mode" in attrs, (
1057+
f"Expected 'mode' attribute when schedule is on: {attrs}"
1058+
)
1059+
logger.info(f"Schedule 'mode' attribute verified: {attrs['mode']}")
1060+
1061+
# DELETE
1062+
await mcp_client.call_tool(
1063+
"ha_config_remove_helper",
1064+
{"helper_type": "schedule", "helper_id": entity_id},
1065+
)
1066+
logger.info("Schedule with data cleanup complete")
1067+
9821068

9831069
@pytest.mark.asyncio
9841070
@pytest.mark.config

0 commit comments

Comments
 (0)