Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,39 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]

### Documentation

- Clarified that ALPHA HWR pumps should be paired/bonded with the host before
telemetry and control are expected to work reliably.


## [0.6.0] - 2026-05-15

### Fixed

- **Authentication extension packet ordering**: `send_extension_packets()` was
sending EXTEND_1 and EXTEND_2 concurrently (via `asyncio.TaskGroup`) with the
wrong submission order. The correct sequence is EXTEND_1 then EXTEND_2 with a
50ms gap. Parallel or reversed delivery caused premature disconnection (#24).
- **`SetpointInfo.control_mode` type**: Field was typed as bare `int`, causing
`AttributeError` on `.name` access. A `field_validator` now coerces known
values to `ControlMode`; unknown values remain as `int`.
- **Double Pa→m conversion**: `ControlService.get_mode()` already converted
pressure setpoints from Pascals to metres, but `get_display_value()` and
`get_limits_display()` divided by 9806.65 a second time. Both model methods
now return the stored value directly for pressure modes.
- **Disconnection guard in `read_once()`**: Added `transport.is_connected()`
checks before the flow/pressure and temperature queries. A mid-sequence
disconnect now returns partial telemetry instead of raising a `BleakError`.

### Changed

- **Minimum Bleak version bumped to 3.0.0** (previously `>=0.19.0`). Bleak 3.0
removed the `adapter=` keyword argument; the library now uses the Bleak 3.x
`bluez={"adapter": ...}` form on Linux and ignores the argument on other
platforms. Users pinned to Bleak 0.x–2.x must upgrade.

## [0.5.0] - 2026-02-21

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Python library and CLI for controlling Grundfos ALPHA HWR pumps via Bluetooth Lo

## Features

- Automatic discovery and pairing with ALPHA HWR pumps
- Automatic discovery and guidance for pairing/bonding ALPHA HWR pumps
- Stream telemetry data (flow, pressure, power, temperature)
- Set pump modes and setpoints with automatic validation
- Create and manage time-based operation schedules
Expand All @@ -27,6 +27,10 @@ pip install alpha-hwr

**Requirements:** Python 3.13+ with Bluetooth Low Energy support

> **Important:** Most pumps require the host to be paired/bonded before
> telemetry and control work reliably. If your OS prompts for pairing on first
> connect, accept it before trying to read data.

## Quick Start

### Command Line
Expand Down
4 changes: 4 additions & 0 deletions docs/getting_started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ This guide covers how to install the `alpha-hwr` library and its dependencies.
* **macOS**: Supported for Control and Telemetry (Schedule download is currently restricted by the OS).
* **Windows**: Supported for Control and Telemetry.

**Pairing / Bonding:** The pump usually needs to be paired with the host
before telemetry is reliable. If the OS or Grundfos app prompts you to pair on
first connection, complete that step before testing the library.

## Installing via pip

*Note: The package is not yet on PyPI. Installation is currently from source.*
Expand Down
4 changes: 4 additions & 0 deletions docs/getting_started/quick_start.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

This guide will help you connect to your Grundfos ALPHA HWR pump, authenticate, and read live telemetry data using Python.

Before you start, make sure the pump is paired/bonded with the laptop or host
you plan to use. On first connection, the operating system may show a pairing
prompt; accept it before expecting telemetry.

## 1. Scan for the Device

First, you need to find the Bluetooth address of your pump. You can use any BLE scanner app (like nRF Connect) or a simple Python script using `bleak`.
Expand Down
3 changes: 3 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@ It provides both a high-level Python API and a Command Line Interface (CLI) for

This project is **not affiliated with, endorsed by, or associated with Grundfos**. Use this software at your own risk. Incorrect usage of motor control commands could potentially damage hardware, although safety limits in the pump firmware generally prevent this.

Most pumps also need to be paired/bonded with the host before telemetry and
control work reliably.

---
*Version: 0.5.0*
4 changes: 2 additions & 2 deletions docs/protocol/connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ All communication (commands and telemetry) happens over a single GATT Characteri
* **Properties**: `Write`, `Notify`

### Pairing
The device typically requests **Pairing/Bonding** upon connection.
The device requires **Pairing/Bonding** for normal use.
* **Level**: `Just Works` (No PIN usually required, though some models might prompt).
* **Requirement**: Bonding is recommended. While some commands might work without it, stable Schedule downloading (HCI layer) and consistent reconnection often rely on a bonded state.
* **Requirement**: Bonding is recommended. While some commands might work without it, telemetry, control, stable Schedule downloading (HCI layer), and consistent reconnection are much more reliable after bonding.

## 2. Authentication Handshake ("Unlock")

Expand Down
15 changes: 15 additions & 0 deletions docs/troubleshooting/service_discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,21 @@ void gatt_discover_complete_callback() {
3. Check pump firmware version (see Class 7 ID 50 after auth)
4. Force re-pair: delete bonding data, reconnect

### Issue 1b: Telemetry Works Only After Manual Pairing

**Symptoms:**
- Authentication appears to succeed, but telemetry is empty or unreliable
- The first successful read happens only after pairing from the OS prompt or
the Grundfos app

**Cause:**
- The pump has not been bonded with the host yet

**Solution:**
1. Pair the pump on the host before testing the library
2. Accept any Bluetooth pairing prompt shown by the operating system
3. If needed, remove the old bond and pair again

### Issue 2: Multiple Pumps Nearby

**Symptoms:**
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
Comment thread
eman marked this conversation as resolved.
"bleak>=0.19.0",
"bleak>=3.0.0",
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"typer>=0.9.0",
Expand Down
217 changes: 217 additions & 0 deletions scripts/verify_hardware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
Hardware verification script for issue #24 regression testing.

Connects to a local ALPHA HWR pump via BLE, reads telemetry and device
information to verify the fix for:
- Sequential extension packet ordering (EXTEND_1 then EXTEND_2)
- Bleak 3.x adapter handling
- Disconnection guard in read_once (is_connected checks)

Usage:
# Auto-discover pump:
.venv/bin/python scripts/verify_hardware.py

# Specify address directly:
.venv/bin/python scripts/verify_hardware.py --address <BLE_ADDRESS>
"""

from __future__ import annotations

import asyncio
import logging
import sys
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as pkg_version

import typer

from alpha_hwr.client import AlphaHWRClient

app = typer.Typer(add_completion=False)


def _pkg_ver(name: str) -> str:
try:
return pkg_version(name)
except PackageNotFoundError:
return "unknown"


def _setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s %(name)s %(levelname)s: %(message)s",
)
if not verbose:
logging.getLogger("bleak").setLevel(logging.WARNING)


async def _run(address: str | None, verbose: bool) -> int:
_setup_logging(verbose)

print(
f"alpha-hwr {_pkg_ver('alpha-hwr')}"
f" | bleak {_pkg_ver('bleak')}"
f" | Python {sys.version.split()[0]}"
)
print()

# ------------------------------------------------------------------ #
# Discovery #
# ------------------------------------------------------------------ #
if address is None:
print("Scanning for ALPHA HWR pumps (10 s)...")
devices = await AlphaHWRClient.discover(timeout=10.0)
if not devices:
print(
"ERROR: No ALPHA HWR pumps found."
" Is the device powered on and in range?"
)
return 1
print(f"Found {len(devices)} pump(s):")
for d in devices:
print(f" - {d.name or 'Unknown'} [{d.address}]")
address = devices[0].address
print(f"\nUsing first device: {address}")
else:
print(f"Using specified address: {address}")

print()

# ------------------------------------------------------------------ #
# Connection + verification #
# ------------------------------------------------------------------ #
passed: list[str] = []
failed: list[str] = []

try:
async with AlphaHWRClient(address) as client:
# Services are guaranteed non-None inside the context manager.
assert client.device_info is not None
assert client.telemetry is not None
assert client.control is not None

print("Connected and authenticated successfully.")
passed.append("Connection and authentication")

# -- Firmware / device info --------------------------------- #
print("\n--- Device Information ---")
try:
info = await client.device_info.read_info()
if info:
print(f" Product name : {info.product_name or 'N/A'}")
print(f" BLE firmware : {info.ble_version or 'N/A'}")
if info.ble_version:
passed.append(f"Firmware detected: {info.ble_version}")
else:
failed.append("BLE firmware version not returned")
else:
print(" (no device info returned)")
failed.append("read_info returned None")
except Exception as exc:
print(f" ERROR reading device info: {exc}")
failed.append(f"Device info error: {exc}")

# -- Telemetry ---------------------------------------------- #
print("\n--- Telemetry ---")
try:
telemetry = await client.telemetry.read_once()
if telemetry is not None:
print(f" Flow : {telemetry.flow_m3h} m3/h")
print(f" Head : {telemetry.head_m} m")
print(f" Power : {telemetry.power_w} W")
if telemetry.flow_m3h is not None:
passed.append("Telemetry flow_m3h populated")
else:
failed.append("flow_m3h is None (possible regression)")
if telemetry.head_m is not None:
passed.append("Telemetry head_m populated")
else:
failed.append("head_m is None (possible regression)")
else:
print(" Telemetry returned None")
failed.append("read_once returned None")
except Exception as exc:
print(f" ERROR reading telemetry: {exc}")
failed.append(f"Telemetry error: {exc}")

# -- Control mode ------------------------------------------- #
print("\n--- Control Mode ---")
try:
mode_info = await client.control.get_mode()
if mode_info:
from alpha_hwr.constants import ControlMode

if isinstance(mode_info.control_mode, ControlMode):
mode_name = mode_info.control_mode.name
else:
mode_name = f"unknown({mode_info.control_mode})"
print(f" Mode : {mode_name}")
value, unit = mode_info.get_display_value()
print(f" Setpoint: {value:.2f} {unit}")
passed.append("Control mode read")
else:
print(" (no control mode data)")
failed.append("get_mode returned None")
except Exception as exc:
print(f" ERROR reading control mode: {exc}")
failed.append(f"Control mode error: {exc}")

except Exception as exc:
print(f"\nFATAL: Could not connect/authenticate: {exc}")
failed.append(f"Connection failed: {exc}")
logging.exception("Connection error")

# ------------------------------------------------------------------ #
# Summary #
# ------------------------------------------------------------------ #
print("\n" + "=" * 50)
print("VERIFICATION SUMMARY")
print("=" * 50)
for item in passed:
print(f" PASS {item}")
for item in failed:
print(f" FAIL {item}")

regression_keywords = [
"Service Discovery",
"BleakError",
"regression",
"None",
]
regressions = [
f for f in failed if any(k in f for k in regression_keywords)
]
if regressions:
print("\nPotential regressions detected (see FAIL lines above).")
return 2

if failed:
print("\nSome checks failed - review output above.")
return 1

print("\nAll checks passed. No regressions detected.")
return 0


@app.command()
def main(
address: str | None = typer.Option(
None,
"--address",
"-a",
help="BLE device address. Auto-discovers if omitted.",
),
verbose: bool = typer.Option(
False, "--verbose", "-v", help="Enable debug logging."
),
) -> None:
"""Verify hardware connectivity and firmware for issue #24 regression."""
exit_code = asyncio.run(_run(address, verbose))
raise SystemExit(exit_code)


if __name__ == "__main__":
app()
8 changes: 7 additions & 1 deletion src/alpha_hwr/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class AlphaHWRClient {
from __future__ import annotations

import logging
import sys
from types import TracebackType
from typing import Self

Expand Down Expand Up @@ -285,7 +286,12 @@ async def connect(
# Create BLE client
if self.address is None:
raise ValueError("BLE device address is not set")
self._bleak_client = BleakClient(self.address, adapter=self.adapter)
if self.adapter and sys.platform.startswith("linux"):
self._bleak_client = BleakClient(
self.address, bluez={"adapter": self.adapter}
)
else:
self._bleak_client = BleakClient(self.address)

Comment thread
eman marked this conversation as resolved.
# Connect to device
await self._bleak_client.connect(timeout=timeout)
Expand Down
Loading
Loading