Skip to content

Schedule Management

The Grundfos ALPHA HWR features an internal weekly schedule system that allows the pump to operate only during specific time windows (e.g., mornings and evenings), saving energy.

The alpha-hwr library supports full schedule management - reading and writing schedules across all 5 layers.

Protocol Overview

Unlike basic telemetry which is streamed automatically, the schedule data must be actively requested using Class 10 DataObject READ operations.

  • Object ID: 84 (0x54) - Clock Program / Schedule
  • SubIDs:
    • 1: Overview (Enabled Status, Metadata)
    • 1000-1004: 5 Independent Schedule Layers (0-4)

Multiple Schedule Layers

The ALPHA HWR supports 5 independent schedule layers that allow complex scheduling patterns:

  • Layer 0 (SubID 1000): Primary schedule (typically weekday mornings)
  • Layer 1 (SubID 1001): Secondary schedule (typically weekday evenings)
  • Layer 2 (SubID 1002): Tertiary schedule (typically weekends)
  • Layer 3 (SubID 1003): Seasonal/temporary adjustments
  • Layer 4 (SubID 1004): Special events or overrides

Key Features: - Each layer contains a full 7-day schedule (42 bytes) - Layers can have overlapping time windows (e.g., Layer 0 and Layer 1 both active on Monday) - The pump operates whenever any layer is active - Layers allow organizing schedules by purpose (weekday/weekend, seasonal, etc.)

Practical Example:

Layer 0: Mon-Fri 6:30-8:30   (weekday morning showers)
Layer 1: Mon-Fri 18:00-22:00 (weekday evening usage)
Layer 2: Sat-Sun 8:00-23:00  (weekend all-day)
Layer 3: Dec-Feb extended    (winter heating boost)
Layer 4: Special events      (holiday adjustments)

Critical Requirements

To successfully read the schedule, the client must follow a strict sequence:

  1. Authentication: The device must be unlocked via the Connection Handshake.
  2. Keep-Alive Burst: Before requesting large data structures, the client must send a burst of "Keep-Alive" packets to wake up the GENI controller fully.
  3. Timing: A delay of ~200-500ms is required after the burst before sending the Read Request.

Data Structure

The schedule is stored as a ClockProgramWeekDayInterval (Type 222).

Read Request

The client sends a standard Class 10 INFO (Read) request.

[Class=0x0A] [OpSpec=0x03] [ObjID=0x54] [SubID=0x03E8 (1000)]

Response Format

The device responds with a Class 10 packet containing the full week's schedule.

  • Header: 3 bytes (Padding/Length)
  • Data: 42 bytes (7 days \(\times\) 6 bytes/day)

Per-Day Structure (6 bytes):

Offset Field Description
0 Enabled 0x01 if this day has an active schedule, 0x00 otherwise.
1 Action Action ID (e.g., 0x02 for Run Pump).
2 Start Hour 0-23
3 Start Minute 0-59
4 End Hour 0-23
5 End Minute 0-59

Day Order: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday.

One-Time Schedule (Vacation Mode)

The pump supports a dedicated "One-Time" schedule slot, often used for "Vacation Mode" or temporary overrides.

  • Object ID: 0x6702 (26370)
  • SubID: 0x5E00 (24064)
  • OpSpec: 0x8B (Set)

Packet Structure

The payload controls whether the one-time schedule is enabled or disabled (deleted).

Enable One-Time Schedule:

4D 01 [StartTimestamp] [EndTimestamp] [Mode] ...
* 4D: Command marker * 01: Enable flag * ... followed by start/end times and mode configuration.

Disable/Delete One-Time Schedule:

4D 00 ...
* 4D: Command marker * 00: Disable flag * ... rest of payload is ignored or zeroed.

Python Implementation

# Set a one-time schedule (e.g., Vacation Mode)
start_time = datetime.now() + timedelta(days=1)
end_time = start_time + timedelta(weeks=1)
await client.set_one_time_schedule(start_time, end_time, ControlMode.CONSTANT_SPEED)

# Delete/Cancel one-time schedule
await client.delete_schedule()

Unsupported Features

Alternative Schedules

Traffic analysis and hardware probing confirmed that "Alternative Schedule" slots (typically associated with SubIDs 1100+) are not supported on this firmware version. The ClockProgramOverview (Obj 84, Sub 1) reports max_nof_alternative_events_per_day = 0.

Python Implementation

Reading Schedules

The AlphaHWRClient handles the complexity of the keep-alive burst and parsing.

async with AlphaHWRClient(address) as client:
    await client.authenticate()

    # Check if schedule is globally enabled
    is_enabled = await client.get_schedule_enabled()
    print(f"Schedule Active: {is_enabled}")

    # Get detailed entries for all layers (consolidated)
    schedule = await client.get_schedule()

    # Get entries for a specific layer (e.g., Layer 0)
    layer0_schedule = await client.get_schedule(layer=0)

    for entry in schedule:
        if entry.enabled:
            print(f"{entry.day}: {entry.begin_time} - {entry.end_time} (Layer {entry.layer})")

Writing Schedules

Enable/Disable Schedule

The global schedule can be enabled or disabled:

# Enable the schedule
success = await client.set_schedule_enabled(True)

# Disable the schedule
success = await client.set_schedule_enabled(False)

Protocol Details:

To enable/disable the schedule, the client: 1. Reads the current ClockProgramOverview data (Object 84, SubID 1) 2. Modifies the complete 10-byte structure with the new enabled flag 3. Writes back using Class 10 SET operation with OpSpec encoding 0x93

Data Structure (ClockProgramOverview - Type 218, 10 bytes):

Byte 0:   max_nof_actions (uint8)
Byte 1:   max_nof_single_events (uint8)
Byte 2:   max_nof_alternative_events_per_day (uint8)
Byte 3:   max_nof_events_per_day (uint8)
Byte 4:   clock_program_enabled (bool) - 0x01 = enabled, 0x00 = disabled
Byte 5:   default_action (SchedulingActionType - uint8)
Bytes 6-9: base_set_point (float32)

Write Request Format:

Start:    0x27 (REQUEST)
Length:   0x17 (23 bytes)
Dest:     0xE7 (pump)
Source:   0xF8 (Local Master)
Class:    0x0A (Class 10)
OpSpec:   0x93 (OpSpec 4, Length 19)
Object:   0x54 (Object ID 84)
SubID:    0x00 0x01 (SubID 1, big-endian)
Reserved: 0x00
Type:     0xDA 0x01 0x00 (Type 218 = ClockProgramOverview, big-endian)
Size:     0x00 0x0A (10 bytes)
Data:     [10 bytes of ClockProgramOverview structure]
CRC:      [2 bytes CRC-16-CCITT with final XOR]

Complete packet example (ENABLE):

2717e7f80a9354000100da0100000a02050005010100000000b44e

Important Implementation Notes: * Complete structure required: Must send all 10 bytes of the structure, not just the enabled flag * Read first: Always read current values to preserve capabilities and settings * Packet splitting: Packets > 20 bytes must be split across multiple BLE writes due to ATT MTU limit (20 bytes max per write) * OpSpec encoding: Use 0x93 (OpSpec 4, Length 19) for the 19-byte APDU * Source address: Use 0xF8 (Local Master), matching standard client behavior * CRC calculation: Use CRC-16-CCITT with final XOR (not the write CRC variant)

OpSpec Encoding Summary

Different schedule operations require different OpSpec codes:

Operation OpSpec OpSpec Value Length Type ID Structure Size
Enable/Disable OpSpec 4 0x93 19 Type 218 (Overview) 10 bytes
Write Schedule OpSpec 5 0xB3 19 Type 222 (Interval) 42 bytes

OpSpec Encoding Format: [OpSpec (3 bits)][Length (5 bits)] - 0x93 = (4 << 5) | 19 = OpSpec 4, Length 19 - 0xB3 = (5 << 5) | 19 = OpSpec 5, Length 19

Key Differences: - OpSpec 0x93: Used for enable/disable (Type 218 ClockProgramOverview, 10 bytes) - OpSpec 0xB3: Used for schedule writes (Type 222 ClockProgramWeekDayInterval, 42 bytes) - The OpSpec determines which structure type the pump expects

Write Schedule Entries

Single Entry

Write a schedule entry for a specific day:

# Set Monday 6:00-8:00
success = await client.set_schedule_entry("Monday", 6, 0, 8, 0)

Protocol Details:

To write a single entry, the client: 1. Reads the current layer data (Object 84, SubIDs 1000-1004) 2. Modifies the 6 bytes for the specific day 3. Writes back using Class 10 SET operation with OpSpec 0xB3

Data Structure (ClockProgramWeekDayInterval - Type 222, 42 bytes):

Per-Day Structure (6 bytes):
[0]: Enabled flag (0x01 = enabled, 0x00 = disabled)
[1]: Action (0x02 = run pump)
[2]: Start hour (0-23)
[3]: Start minute (0-59)
[4]: End hour (0-23)
[5]: End minute (0-59)

Day Order: Monday (offset 0), Tuesday (offset 6), Wednesday (offset 12), Thursday (offset 18), Friday (offset 24), Saturday (offset 30), Sunday (offset 36)

Write Request Format:

Start:    0x27 (REQUEST)
Length:   [varies - typically 55 bytes for APDU, 59 bytes total]
Dest:     0xE7 (pump)
Source:   0xF8 (Local Master)
Class:    0x0A (Class 10)
OpSpec:   0xB3 (OpSpec 5, Length 19)
Object:   0x54 (Object ID 84)
SubID:    [SubID for layer, big-endian] (1000-1004)
Reserved: 0x00
Type:     0xDE 0x01 0x00 (Type 222 = ClockProgramWeekDayInterval)
Size:     0x00 0x2A (42 bytes)
Data:     [42 bytes of schedule data - 7 days × 6 bytes]
CRC:      [2 bytes CRC-16-CCITT with final XOR]

Complete packet example (Layer 0, SubID 1000):

2737e7f80ab35403e800de0100002a0102052d09000102052d09000102062d09000102062d09000102071e09000102081e16000102081e160092f3

Important Implementation Notes: * OpSpec encoding: Use 0xB3 (OpSpec 5, Length 19) for schedule write operations * Byte order: Object ID comes BEFORE SubID (opposite of older protocol variants) * Complete data required: Must send all 42 bytes (full week), even when modifying a single day * Packet splitting: 59-byte packets must be split: 20 + 20 + 19 bytes across multiple BLE writes due to ATT MTU limit * Type header: Must include Type 222 header (0xDE 0x01 0x00) and size (0x00 0x2A)

Weekly Schedule

Write multiple entries at once:

from alpha_hwr import ScheduleEntry

entries = [
    ScheduleEntry(day="Monday", begin_hour=6, begin_minute=0, end_hour=8, end_minute=0),
    ScheduleEntry(day="Monday", begin_hour=18, begin_minute=0, end_hour=20, end_minute=0),
    ScheduleEntry(day="Tuesday", begin_hour=6, begin_minute=0, end_hour=8, end_minute=0),
]

success = await client.set_weekly_schedule(entries, layer=0)

Protocol Details:

To write a weekly schedule, the client: 1. Validates all entries for conflicts (using validate_schedule()) 2. Builds a 42-byte payload (7 days × 6 bytes) 3. Writes to the specified layer (SubIDs 1000-1004) using OpSpec 0xB3

Important: This method overwrites the entire layer. Days not included in the entries list will be cleared (disabled).

Write Request Format:

Class:    0x0A (Class 10)
OpSpec:   0xB3 (OpSpec 5, Length 19)
Object:   0x54 (Object ID 84)
SubID:    [SubID for layer, big-endian] (1000-1004)
Reserved: 0x00
Type:     0xDE 0x01 0x00 (Type 222)
Size:     0x00 0x2A (42 bytes)
Data:     [42 bytes of schedule data]

For layer 0 (SubID 1000), the APDU is:

0A B3 54 03 E8 00 DE 01 00 00 2A [42 bytes...]

Clear Schedule Entries

Clear Single Day

Clear a schedule entry for a specific day:

# Clear Monday on layer 0
success = await client.clear_schedule_entry("Monday", layer=0)

Protocol Details:

To clear a single day, the client: 1. Reads the current layer data (Object 84, SubIDs 1000-1004) 2. Sets the 6 bytes for the specific day to 0x00 (disabled) 3. Writes back using Class 10 SET operation with OpSpec 0xB3

This preserves other days on the same layer. Uses the same packet format as set_schedule_entry().

Clear Entire Layer

Clear all schedule entries on a specific layer:

# Clear all entries on layer 0
success = await client.clear_schedule_layer(0)

Protocol Details:

To clear a layer, the client: 1. Builds a 42-byte payload of all zeros 2. Writes to the specified layer (SubIDs 1000-1004) using OpSpec 0xB3

This is more efficient than clearing individual days. Uses the same packet format as set_weekly_schedule().

Clear All Schedules

Clear all schedule entries on all 5 layers:

# Clear everything
success = await client.clear_all_schedules()

Protocol Details:

This method iterates through all 5 layers (0-4) and clears each one. Returns True only if all 5 layers are successfully cleared.

Export/Import JSON

Export Schedule

Export the current schedule to a JSON file:

# Export current schedule
success = await client.export_schedule_json("schedule.json")

Details:

The export method: 1. Reads the complete schedule using get_schedule() 2. Writes the data to a JSON file with proper formatting 3. Creates parent directories if needed

JSON Format:

{
  "enabled": true,
  "days": [
    {
      "day": "Monday",
      "enabled": true,
      "action": 2,
      "begin_hour": 6,
      "begin_minute": 0,
      "end_hour": 8,
      "end_minute": 0,
      "begin_time": "06:00",
      "end_time": "08:00"
    }
  ]
}
Import Schedule

Import a schedule from a JSON file:

# Import from file
import json
from alpha_hwr import ScheduleEntry

with open("schedule.json", 'r') as f:
    data = json.load(f)

entries = [ScheduleEntry(**entry) for entry in data['days']]
success = await client.set_weekly_schedule(entries, layer=0)

Or use the CLI:

alpha-hwr schedule --import-json schedule.json --layer 0

Working with Multiple Layers

Understanding Schedule Layers

The ALPHA HWR pump supports 5 independent schedule layers that enable sophisticated scheduling patterns. Each layer is a complete 7-day schedule stored independently.

Why Multiple Layers

  1. Separation of Concerns: Keep different schedule types separate
  2. Layer 0: Weekday mornings
  3. Layer 1: Weekday evenings
  4. Layer 2: Weekend schedule
  5. Layer 3: Seasonal adjustments
  6. Layer 4: Special events

  7. Overlapping Time Windows: Different layers can be active simultaneously

  8. Monday 6:00-9:00 (Layer 0: morning)
  9. Monday 7:00-10:00 (Layer 1: extended morning)
  10. Result: Pump runs 6:00-10:00 (union of both)

  11. Easier Maintenance: Modify one schedule type without affecting others

  12. Update weekend schedule (Layer 2) without touching weekday schedules
  13. Add seasonal boost (Layer 3) without changing base schedule

Layer Best Practices

Pattern 1: Time-of-Day Separation

# Layer 0: Morning schedule
morning = [
    ScheduleEntry(day="Monday", begin_hour=6, begin_minute=30, end_hour=8, end_minute=30),
    ScheduleEntry(day="Tuesday", begin_hour=6, begin_minute=30, end_hour=8, end_minute=30),
    # ... rest of weekdays
]

# Layer 1: Evening schedule
evening = [
    ScheduleEntry(day="Monday", begin_hour=18, begin_minute=0, end_hour=22, end_minute=0),
    ScheduleEntry(day="Tuesday", begin_hour=18, begin_minute=0, end_hour=22, end_minute=0),
    # ... rest of weekdays
]

await client.set_weekly_schedule(morning, layer=0)
await client.set_weekly_schedule(evening, layer=1)

Pattern 2: Weekday/Weekend Separation

# Layers 0-1: Weekday schedules (as above)
# Layer 2: Weekend all-day
weekend = [
    ScheduleEntry(day="Saturday", begin_hour=8, begin_minute=0, end_hour=23, end_minute=0),
    ScheduleEntry(day="Sunday", begin_hour=8, begin_minute=0, end_hour=23, end_minute=0),
]

await client.set_weekly_schedule(weekend, layer=2)

Pattern 3: Seasonal Adjustments

# Layers 0-2: Year-round schedules
# Layer 3: Winter boost (December-February)
winter_boost = [
    ScheduleEntry(day="Monday", begin_hour=5, begin_minute=0, end_hour=6, end_minute=30),
    # ... pre-morning heating boost
]

# Enable in winter, clear in summer
if is_winter:
    await client.set_weekly_schedule(winter_boost, layer=3)
else:
    await client.clear_schedule_layer(3)

Reading from Specific Layers

# Read all layers (combined view)
schedule = await client.get_schedule()
# Returns all enabled entries from all 5 layers

# Read specific layer (direct)
layer0_data = await client.get_schedule(layer=0)
layer2_data = await client.get_schedule(layer=2)

Layer Operations

# Write to specific layer
await client.set_weekly_schedule(entries, layer=2)

# Clear specific layer
await client.clear_schedule_layer(2)

# Clear all layers
await client.clear_all_schedules()

# Single entry affects only one layer
await client.set_schedule_entry("Monday", 6, 0, 8, 0, layer=0)
await client.clear_schedule_entry("Monday", layer=0)

Layer Validation

Per-Layer Validation: The validation system checks for overlaps within each layer:

# This is VALID (different layers)
layer0 = [ScheduleEntry(day="Monday", begin_hour=6, begin_minute=0, end_hour=9, end_minute=0)]
layer1 = [ScheduleEntry(day="Monday", begin_hour=7, begin_minute=0, end_hour=10, end_minute=0)]

await client.set_weekly_schedule(layer0, layer=0)  #  Valid
await client.set_weekly_schedule(layer1, layer=1)  #  Valid
# Result: Monday 6:00-10:00 (overlapping is allowed across layers)

# This is INVALID (same layer)
entries = [
    ScheduleEntry(day="Monday", begin_hour=6, begin_minute=0, end_hour=9, end_minute=0),
    ScheduleEntry(day="Monday", begin_hour=7, begin_minute=0, end_hour=10, end_minute=0),
]
await client.set_weekly_schedule(entries, layer=0)  #  Validation error
# Error: Overlap detected: Monday layer 0: 06:00-09:00 overlaps with 07:00-10:00

Layer Interaction with Pump Operation

The pump operates when any layer has an active time window:

gantt
    title Monday Schedule (Combined Layers)
    dateFormat HH:mm
    axisFormat %H:%M

    section Layer 0
    Morning (6:00-9:00) :active, l0, 06:00, 09:00

    section Layer 1
    Late Morning (8:00-11:00) :active, l1, 08:00, 11:00

    section Layer 2
    Noon (10:00-12:00) :active, l2, 10:00, 12:00

    section Combined
    Pump ON (6:00-12:00) :crit, done, 06:00, 12:00

The pump runs from 6:00-12:00 (union of all active layers).

CLI Layer Management

# Write to specific layer
alpha-hwr schedule --set-entry Monday 6 0 9 0 --layer 0

# Set weekly schedule on layer
alpha-hwr schedule --import-json weekday.json --layer 0
alpha-hwr schedule --import-json weekend.json --layer 2

# Clear specific layer
alpha-hwr schedule --clear-layer 3

# View all layers (combined)
alpha-hwr schedule

Limitations

  1. Multi-Layer Support: The library supports all 5 schedule layers (0-4) for reading and writing.
  2. Validation Required: Always validate schedules before writing to prevent overlaps within the same layer.
  3. Cross-Layer Overlaps: Overlapping time windows across different layers are allowed and intentional.

Schedule Validation

The library includes comprehensive validation for schedule entries to prevent conflicts and errors.

ScheduleEntry Model

The ScheduleEntry model provides a structured way to work with schedule time windows:

from alpha_hwr import ScheduleEntry

entry = ScheduleEntry(
    day="Monday",
    begin_hour=6,
    begin_minute=0,
    end_hour=8,
    end_minute=0,
    layer=0,  # Schedule layer (0-4)
    action=0x02,  # Run pump
    enabled=True
)

Validation Features

Automatic Field Validation: * Day names must be valid weekdays (Monday-Sunday) * Hours must be 0-23 * Minutes must be 0-59 * Layer must be 0-4

Time Range Validation: * Zero-duration entries are rejected * Midnight-crossing schedules are supported (e.g., 22:00-02:00)

Overlap Detection: * Detects overlapping time windows on same day/layer * Handles midnight-crossing overlaps correctly * Ignores disabled entries and different layers

Example:

# Validate a list of schedule entries
entries = [
    ScheduleEntry(day="Monday", begin_hour=6, begin_minute=0, end_hour=8, end_minute=0),
    ScheduleEntry(day="Monday", begin_hour=7, begin_minute=0, end_hour=9, end_minute=0),  # Overlaps!
]

is_valid, errors = client.validate_schedule(entries)
if not is_valid:
    for error in errors:
        print(f"Error: {error}")
# Output: Error: Overlap detected: Monday layer 0: 06:00-08:00 overlaps with 07:00-09:00

Duration Calculation

Calculate schedule entry duration, including midnight-crossing:

# Normal entry
entry = ScheduleEntry(day="Monday", begin_hour=6, begin_minute=0, end_hour=8, end_minute=0)
print(entry.get_duration_minutes())  # 120 minutes (2 hours)

# Midnight-crossing entry
entry = ScheduleEntry(day="Monday", begin_hour=22, begin_minute=0, end_hour=2, end_minute=0)
print(entry.get_duration_minutes())  # 240 minutes (4 hours)
print(entry.crosses_midnight())  # True