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
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:
- Authentication: The device must be unlocked via the Connection Handshake.
- Bonding: An unbonded connection is dropped at about 1.8 seconds, whether or not anything is being sent. A schedule read will not complete on one.
- Keep-Alive Burst: Before requesting large data structures, send a burst of keep-alive packets to wake the GENI controller fully.
- Timing: Allow ~200-500 ms after the burst before 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.
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.
Single Events (One-Time Schedules and Vacations)
A single event is a one-time window layered over the weekly schedule. Auto
makes the pump run for it; Stop holds it off, which is how a vacation is
expressed.
Earlier revisions of this page described an object at
0x6702/ SubID0x5E00with a4D-prefixed payload, and aset_one_time_schedule()API on the client. None of it exists. Single events live in Object 84 alongside the weekly schedule.
- Object: 84 (
0x54) - Sub-IDs: 900 upward, one per slot
- Type: 220 (
0xDC01,ClockProgramSingleEvent) - OpSpec:
0xB3(long SET) - Structure: 10 bytes
Structure
| Field | Values |
|---|---|
enabled |
0x01 set, 0x00 cleared |
action |
0x01 Stop, 0x02 Run |
begin, end |
Local Unix timestamps, big-endian |
The action byte's sense is inverted relative to the weekly schedule
In the weekly schedule's default_action, 0x01 means Stop. In a single
event, 0x01 is also Stop but 0x02 is Run — there is no 0x00. This is
the pump's encoding, not a documentation slip.
Full write frame
Slot capacity comes from the pump
The sub-id range suggests 35 slots. The unit this was measured against
exposes 5, and reading past them simply goes unanswered. Read the count
from the ClockProgramOverview (Object 84 Sub 1) rather than assuming.
Timestamps are local Unix time
The failure mode verification cannot catch
The wire value is the wall clock stamped as though it were UTC —
timegm(local_fields) — matching the pump's own RTC, which reports bare
wall-clock fields with no offset.
Encoding these as real UTC round-trips byte-identically. The write is acknowledged, a readback agrees with itself, and the event opens hours from where it was meant to. Nothing detects it except a clock and a running motor.
Established by writing an event under this encoding and watching the motor start four seconds from the intended wall clock.
The clock program must be running
Both confirmed on hardware, by watching a window open with the motor at 0 RPM:
- A stopped pump ignores single events entirely — the run state gates the whole clock program, weekly schedule included.
- Disabling the weekly schedule disables single events too. They are the same program; there is no separate switch.
Python
from datetime import datetime
# A one-off run window
slot = await client.single_events.find_free_slot()
await client.single_events.write(
slot, datetime(2026, 8, 10, 6, 0), datetime(2026, 8, 10, 8, 0)
)
# A vacation - a Stop event
await client.single_events.set_vacation(
datetime(2026, 8, 10), datetime(2026, 8, 17)
)
await client.single_events.clear_vacation()
Unsupported Features
Alternative Schedules
Testing 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.schedule.get_state()
print(f"Schedule Active: {is_enabled}")
# Get detailed entries for all layers (consolidated)
schedule = await client.schedule.read_entries()
# Get entries for a specific layer (e.g., Layer 0)
layer0_schedule = await client.schedule.read_entries(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.schedule.enable()
# Disable the schedule
success = await client.schedule.disable()
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):
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
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:
from alpha_hwr import ScheduleEntry
# Entries are written a whole layer at a time - there is no partial
# update on the wire, so read, edit and write all 42 bytes back.
entries = await client.schedule.read_entries(layer=0)
entries = [e for e in entries if e.day != "Monday"]
entries.append(
ScheduleEntry(
day="Monday",
begin_hour=6,
begin_minute=0,
end_hour=8,
end_minute=0,
layer=0,
)
)
success = await client.schedule.write_entries(entries, layer=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.schedule.write_entries(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:
Clear Schedule Entries
Clear Single Day
Clear a schedule entry for a specific day:
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 ScheduleService.write_entries().
Clear Entire Layer
Clear all schedule entries on a specific layer:
# Clear all entries on layer 0 - write an empty layer
success = await client.schedule.write_entries([], 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 ScheduleService.write_entries().
Clear All Schedules
Clear all schedule entries on all 5 layers:
# Clear everything - one write per layer
for layer in range(5):
await client.schedule.write_entries([], layer=layer)
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 and Import
There is no schedule-specific export API. The schedule travels inside a full configuration backup:
The backup's schedule section carries the enabled flag and every layer's
entries. See Backup & Restore — including the
warning that restore drives the unverified setters.
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
- Separation of Concerns: Keep different schedule types separate
- Layer 0: Weekday mornings
- Layer 1: Weekday evenings
- Layer 2: Weekend schedule
- Layer 3: Seasonal adjustments
-
Layer 4: Special events
-
Overlapping Time Windows: Different layers can be active simultaneously
- Monday 6:00-9:00 (Layer 0: morning)
- Monday 7:00-10:00 (Layer 1: extended morning)
-
Result: Pump runs 6:00-10:00 (union of both)
-
Easier Maintenance: Modify one schedule type without affecting others
- Update weekend schedule (Layer 2) without touching weekday schedules
- 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.schedule.write_entries(morning, layer=0)
await client.schedule.write_entries(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.schedule.write_entries(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.schedule.write_entries(winter_boost, layer=3)
else:
await client.schedule.write_entries([], layer=3)
Reading from Specific Layers
# Read all layers (combined view)
schedule = await client.schedule.read_entries()
# Returns all enabled entries from all 5 layers
# Read specific layer (direct)
layer0_data = await client.schedule.read_entries(layer=0)
layer2_data = await client.schedule.read_entries(layer=2)
Layer Operations
# Write to specific layer
await client.schedule.write_entries(entries, layer=2)
# Clear specific layer
await client.schedule.write_entries([], layer=2)
# Clear all layers
for layer in range(5):
await client.schedule.write_entries([], layer=layer)
# Single entry affects only one layer
await client.schedule.write_entries(entries, layer=0)
await client.schedule.clear_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.schedule.write_entries(layer0, layer=0) # Valid
await client.schedule.write_entries(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.schedule.write_entries(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
- Multi-Layer Support: The library supports all 5 schedule layers (0-4) for reading and writing.
- Validation Required: Always validate schedules before writing to prevent overlaps within the same layer.
- Cross-Layer Overlaps: Overlapping time windows across different layers are allowed and intentional.
Schedule Validation
The library includes 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.schedule.validate_entries(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