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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@

## [Unreleased]

### Fixed

- **Misleading error on mid-read disconnect**: `_read_class10_object()` now
raises `ConnectionError` when the BLE link drops while waiting for a
response, instead of silently returning `None`. Previously a disconnect
during `control status` (or any other Class 10 read) surfaced as
"Setpoint data too short or missing: 0 bytes" / "Could not read control
mode", masking the real cause. The CLI now reports "Pump disconnected
from BLE while reading Object X/Y" instead.

### Documentation

- Clarified that ALPHA HWR pumps should be paired/bonded with the host before
Expand Down
2 changes: 1 addition & 1 deletion src/alpha_hwr/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
# Authentication Sequences
# ============================================================================
# Legacy/Nested Magic Packet
AUTH_LEGACY_MAGIC = bytes.fromhex("2707e7f80203949596eb47")
AUTH_LEGACY_MAGIC = bytes.fromhex("2707fff802039495964f91")

# Class 10 Unlock Packet
AUTH_CLASS10_MAGIC = bytes.fromhex("2707e7f80a03560006c55a")
Expand Down
29 changes: 18 additions & 11 deletions src/alpha_hwr/core/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,21 +248,28 @@ async def authenticate(self, fast_mode: bool = False) -> bool:
try:
# Stage 1: Legacy Magic Burst (backward compatibility)
logger.debug("Stage 1: Sending legacy magic burst (3x repeats)...")
await self.send_legacy_burst(delay=0 if fast_mode else 0.05)
if not fast_mode:
await self.send_legacy_burst(repeats=3, delay=0 if fast_mode else 0.1)
if fast_mode:
# When resuming a session, usually only Stage 3 is strictly required
# but we send all for robustness
pass
elif not fast_mode:
await asyncio.sleep(0.1) # Allow processing time

# Stage 2: Class 10 Unlock Burst (primary auth)
logger.debug(
"Stage 2: Sending Class 10 unlock burst (5x repeats)..."
)
await self.send_class10_burst(delay=0 if fast_mode else 0.05)
if not fast_mode:
await asyncio.sleep(0.2) # Allow authentication to complete
# Stage 2: Class 10 Unlock (required for DataObjects)
logger.debug("Stage 2: Sending Class 10 unlock burst (5x repeats)...")
delay = 0 if fast_mode else 0.1
for _ in range(5):
await self.ble_writer.write_gatt_char(
self.GENI_CHAR_UUID, self.CLASS10_UNLOCK, response=False
)
if delay > 0:
await asyncio.sleep(delay)

# Stage 3: Extension Packets (session establishment)
logger.debug("Stage 3: Sending extension packets...")
await self.send_extension_packets(delay=0 if fast_mode else 0.05)
await self.send_extension_packets(delay=0 if fast_mode else 0.1)

if not fast_mode:
await asyncio.sleep(0.5) # Final stabilization

Expand All @@ -274,7 +281,7 @@ async def authenticate(self, fast_mode: bool = False) -> bool:
return False

async def send_legacy_burst(
self, repeats: int = 3, delay: float = 0.05
self, repeats: int = 7, delay: float = 0.05
) -> None:
"""
Send legacy magic packet burst.
Expand Down
43 changes: 43 additions & 0 deletions src/alpha_hwr/core/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,49 @@ async def query(
logger.debug("Query timeout waiting for matching response")
return None

async def send_wake_burst(
self,
repeats: int = 3,
packet_delay: float = 0.1,
wake_delay: float = 0.3,
) -> None:
"""
Send a wake-up burst to rouse a sleeping GENI controller.

Some GENI controllers enter a low-power sleep state between
operations (e.g. right after the authentication handshake, or
after the connection has been idle). A read issued while the
controller is asleep goes unanswered, and on some platforms a
second read attempt shortly after can even trip a full BLE
disconnect. Sending a short burst of keep-alive packets first
wakes the controller so the actual read gets a response.

Parameters
----------
repeats : int, default=3
Number of keep-alive packets to send.
packet_delay : float, default=0.1
Delay between each keep-alive packet.
wake_delay : float, default=0.3
Delay after the burst to allow the controller to wake up
before issuing the real request.

Notes
-----
See docs/protocol/ble_architecture.md ("Keep-Alive Burst") for the
protocol rationale behind this sequence.
"""
from ..protocol.frame_builder import FrameBuilder

keep_alive_packet = FrameBuilder.build_command_info(0x02, 0x01)

for _ in range(repeats):
await self.write(keep_alive_packet, response=False)
await asyncio.sleep(packet_delay)

await asyncio.sleep(wake_delay)
logger.debug("Wake burst sent")

async def start_keep_alive(self, interval: float = 30.0) -> None:
"""
Start keep-alive task.
Expand Down
147 changes: 114 additions & 33 deletions src/alpha_hwr/services/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@

from __future__ import annotations

import asyncio
import logging
from typing import TYPE_CHECKING, Optional

from ..exceptions import ConnectionError

if TYPE_CHECKING:
from alpha_hwr.core.session import Session
from alpha_hwr.core.transport import Transport
Expand Down Expand Up @@ -45,7 +48,11 @@ def __init__(self, transport: Transport, session: Session):
self.session = session

async def _read_class10_object(
self, obj_id: int, sub_id: int
self,
obj_id: int,
sub_id: int,
retries: int = 1,
retry_delay: float = 0.2,
) -> Optional[bytes]:
"""
Read a Class 10 (Configuration) object with SubID.
Expand All @@ -56,10 +63,28 @@ async def _read_class10_object(
Args:
obj_id: Object ID (0-255)
sub_id: Sub-ID (0-65535)
retries: Number of attempts before giving up (default 1, i.e.
no retry). Some object/sub-ID pairs are read in bulk over a
known range where a missing response legitimately means "no
data" (e.g. an empty event-log slot or an unsupported trend
series), so retries must stay opt-in rather than automatic.
Pass a higher value for one-off status reads (such as the
control mode query right after authentication) where the
pump may briefly be unresponsive while it finishes settling
and a missing response really does mean "try again".
retry_delay: Delay in seconds between attempts.

Returns:
Data bytes (payload only, without frame header/CRC), or None if read failed

Raises:
ConnectionError: If the BLE connection to the pump is found to
have dropped while waiting for a response. This is
distinct from a plain timeout (which returns None) because
a lost connection means every subsequent read will fail
the same way, so callers should stop and surface a clear
error instead of reporting "no data".

Example:
>>> data = await self._read_class10_object(93, 1) # Read statistics
>>> data = await self._read_class10_object(86, 6) # Read control mode
Expand Down Expand Up @@ -90,35 +115,74 @@ async def _read_class10_object(
crc = calc_crc16_read(frame_without_crc[1:])
frame = frame_without_crc + bytes([(crc >> 8) & 0xFF, crc & 0xFF])

logger.debug(f"Reading Class 10 Object {obj_id} SubID {sub_id}")

def match_class10_response(p: bytes) -> bool:
"""Match Class 10 response packet."""
return len(p) > 6 and p[4] == 0x0A

response = await self.transport.query(
frame,
match_func=match_class10_response,
timeout=3.0,
)
for attempt in range(1, retries + 1):
logger.debug(
f"Reading Class 10 Object {obj_id} SubID {sub_id} "
f"(attempt {attempt}/{retries})"
)

response = await self.transport.query(
frame,
match_func=match_class10_response,
timeout=3.0,
)

if response and len(response) > 12:
# Extract data: skip frame header (10 bytes) and CRC (2 bytes)
# Frame structure: [STX][LEN][DST][SRC][Class][OpSpec][ObjH][ObjL][SubH][SubL][DATA...][CRC_H][CRC_L]
payload = response[10:-2]
logger.debug(
f"Read Object {obj_id}/{sub_id}: {len(payload)} bytes"
)
return payload

if response and len(response) > 12:
# Extract data: skip frame header (10 bytes) and CRC (2 bytes)
# Frame structure: [STX][LEN][DST][SRC][Class][OpSpec][ObjH][ObjL][SubH][SubL][DATA...][CRC_H][CRC_L]
payload = response[10:-2]
logger.debug(
f"Read Object {obj_id}/{sub_id}: {len(payload)} bytes"
f"No response for Object {obj_id}/{sub_id} "
f"(attempt {attempt}/{retries})"
)
return payload

logger.debug(f"No response for Object {obj_id}/{sub_id}")
if not self.transport.is_connected():
# The connection dropped out from under us. Every
# further attempt (and every other read on this
# session) will fail identically, so raise instead of
# quietly returning None - the caller needs to know
# this isn't "no data", it's a lost BLE link.
raise ConnectionError(
f"Pump disconnected from BLE while reading "
f"Object {obj_id}/{sub_id}"
)

if attempt < retries:
# The pump's GENI controller may be asleep (common right
# after authentication or after an idle period). Wake it
# with a keep-alive burst before retrying instead of
# just re-sending the same request, since a bare retry
# can otherwise trip a full BLE disconnect on some
# platforms. See docs/protocol/ble_architecture.md.
await self.transport.send_wake_burst()
if retry_delay > 0:
await asyncio.sleep(retry_delay)

return None

except ConnectionError:
# Let disconnect errors propagate - see docstring above.
raise

except Exception as e:
logger.debug(f"Error reading Object {obj_id} SubID {sub_id}: {e}")
return None

async def _read_class7_string(self, string_id: int) -> Optional[str]:
async def _read_class7_string(
self,
string_id: int,
retries: int = 1,
retry_delay: float = 0.2,
) -> Optional[str]:
"""
Read a Class 7 string (device info strings).

Expand All @@ -127,6 +191,11 @@ async def _read_class7_string(self, string_id: int) -> Optional[str]:

Args:
string_id: String ID to read
retries: Number of attempts before giving up (default 1, i.e.
no retry). Pass a higher value for critical reads where a
missing response likely means the pump hasn't finished
settling yet rather than "this string does not exist".
retry_delay: Delay in seconds between attempts.

Returns:
String value, or None if read failed
Expand All @@ -151,26 +220,38 @@ def match_class7(p: bytes) -> bool:
"""Match Class 7 response."""
return len(p) > 6 and p[4] == 0x07

response = await self.transport.query(
frame,
match_func=match_class7,
timeout=3.0,
)
for attempt in range(1, retries + 1):
response = await self.transport.query(
frame,
match_func=match_class7,
timeout=3.0,
)

if response and len(response) > 9:
# Extract string data: skip frame header (7 bytes) and CRC (2 bytes)
# Frame: [STX][LEN][DST][SRC][Class][Cmd][ID][...STRING...][CRC_H][CRC_L]
string_data = response[7:-2]
logger.debug(
f"Raw string data for ID {string_id}: {string_data.hex()}"
)
# Decode as UTF-8, strip null terminators and whitespace
string_value = (
string_data.decode("utf-8", errors="ignore")
.rstrip("\x00")
.strip()
)
return string_value if string_value else None

if response and len(response) > 9:
# Extract string data: skip frame header (7 bytes) and CRC (2 bytes)
# Frame: [STX][LEN][DST][SRC][Class][Cmd][ID][...STRING...][CRC_H][CRC_L]
string_data = response[7:-2]
logger.debug(
f"Raw string data for ID {string_id}: {string_data.hex()}"
)
# Decode as UTF-8, strip null terminators and whitespace
string_value = (
string_data.decode("utf-8", errors="ignore")
.rstrip("\x00")
.strip()
f"No response for string {string_id} "
f"(attempt {attempt}/{retries})"
)
return string_value if string_value else None
if attempt < retries:
# See _read_class10_object for why we wake the GENI
# controller before retrying instead of just resending.
await self.transport.send_wake_burst()
if retry_delay > 0:
await asyncio.sleep(retry_delay)

return None

Expand Down
Loading
Loading