Skip to content
Merged
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "python-qube-heatpump"
version = "1.14.0"
version = "1.15.0"
authors = [
{ name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
]
Expand Down
314 changes: 229 additions & 85 deletions src/python_qube_heatpump/client.py

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src/python_qube_heatpump/entities/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,10 @@ class EntityDef:
# Value precision
precision: int | None = None
"""Number of decimal places to round to. None for no rounding."""

# Write limits
min_value: float | None = None
"""Lowest value write_setpoint() accepts, in the entity's unit."""

max_value: float | None = None
"""Highest value write_setpoint() accepts, in the entity's unit."""
8 changes: 8 additions & 0 deletions src/python_qube_heatpump/entities/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
platform=Platform.SENSOR,
unit="°C",
writable=True, # User-override setpoint, bypasses heating curve
min_value=20.0,
max_value=65.0,
),
EntityDef(
key="usr_pid_coolsetp",
Expand All @@ -95,6 +97,8 @@
platform=Platform.SENSOR,
unit="°C",
writable=True, # User-override setpoint, bypasses cooling curve
min_value=7.0,
max_value=25.0,
),
EntityDef(
key="regulation_buffersetp_max",
Expand All @@ -114,6 +118,8 @@
platform=Platform.SENSOR,
unit="°C",
writable=True, # This setpoint can be written via Modbus
min_value=40.0,
max_value=65.0,
),
# Input register sensors (read-only)
EntityDef(
Expand Down Expand Up @@ -460,6 +466,8 @@
platform=Platform.SENSOR,
unit="°C",
writable=True,
min_value=40.0,
max_value=65.0,
),
)

Expand Down
5 changes: 3 additions & 2 deletions src/python_qube_heatpump/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ async def async_get_mac_address(host: str, port: int = MODBUS_PORT) -> str | Non
Returns:
MAC address as lowercase colon-separated string, or None.
"""
ip = _resolve_ip(host)
# Both lookups block (DNS, file I/O), so keep them off the event loop
ip = await asyncio.to_thread(_resolve_ip, host)
if ip is None:
_LOGGER.debug("Could not resolve host %s to IP", host)
return None
Expand All @@ -71,4 +72,4 @@ async def async_get_mac_address(host: str, port: int = MODBUS_PORT) -> str | Non
_LOGGER.debug("Could not connect to %s:%s to populate ARP", ip, port)
return None

return _read_arp_table(ip)
return await asyncio.to_thread(_read_arp_table, ip)
5 changes: 3 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pytest
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock


@pytest.fixture
Expand All @@ -8,7 +8,8 @@ def mock_modbus_client(mocker):
mock_client = mocker.patch("python_qube_heatpump.client.AsyncModbusTcpClient")
mock_instance = mock_client.return_value
mock_instance.connect = AsyncMock(return_value=True)
mock_instance.close = AsyncMock()
# pymodbus close() is synchronous
mock_instance.close = MagicMock()
mock_instance.read_holding_registers = AsyncMock()
mock_instance.read_input_registers = AsyncMock()
mock_instance.write_register = AsyncMock()
Expand Down
37 changes: 14 additions & 23 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,16 +456,12 @@ async def test_get_sg_ready_mode(mock_modbus_client):
mock_instance = mock_modbus_client.return_value
client._client = mock_instance

# Mock coil reads: A=False, B=True -> "plus"
mock_resp_a = MagicMock()
mock_resp_a.isError.return_value = False
mock_resp_a.bits = [False]

mock_resp_b = MagicMock()
mock_resp_b.isError.return_value = False
mock_resp_b.bits = [True]
# Both coils come back in one block read: A=False, B=True -> "plus"
mock_resp = MagicMock()
mock_resp.isError.return_value = False
mock_resp.bits = [False, True]

mock_instance.read_coils = AsyncMock(side_effect=[mock_resp_a, mock_resp_b])
mock_instance.read_coils = AsyncMock(return_value=mock_resp)

result = await client.get_sg_ready_mode()
assert result == "plus"
Expand All @@ -485,13 +481,10 @@ async def test_get_sg_ready_mode_all_modes(mock_modbus_client):
((True, True), "max"),
]
for (bit_a, bit_b), expected_mode in cases:
resp_a = MagicMock()
resp_a.isError.return_value = False
resp_a.bits = [bit_a]
resp_b = MagicMock()
resp_b.isError.return_value = False
resp_b.bits = [bit_b]
mock_instance.read_coils = AsyncMock(side_effect=[resp_a, resp_b])
resp = MagicMock()
resp.isError.return_value = False
resp.bits = [bit_a, bit_b]
mock_instance.read_coils = AsyncMock(return_value=resp)

result = await client.get_sg_ready_mode()
assert result == expected_mode, (
Expand All @@ -516,23 +509,21 @@ async def test_get_sg_ready_mode_read_error(mock_modbus_client):

@pytest.mark.asyncio
async def test_set_sg_ready_mode(mock_modbus_client):
"""Test setting SG Ready mode writes both coils."""
"""Test setting SG Ready mode writes both coils in one request."""
client = QubeClient("1.2.3.4", 502)
mock_instance = mock_modbus_client.return_value
client._client = mock_instance

mock_resp = MagicMock()
mock_resp.isError.return_value = False
mock_instance.write_coil = AsyncMock(return_value=mock_resp)
mock_instance.write_coils = AsyncMock(return_value=mock_resp)

result = await client.set_sg_ready_mode("plus")
assert result is True

# "plus" = (False, True) -> A=False, B=True
calls = mock_instance.write_coil.call_args_list
assert len(calls) == 2
assert calls[0].args == (65, False) # bms_sgready_a address
assert calls[1].args == (66, True) # bms_sgready_b address
# "plus" = (False, True) -> A=False (coil 65), B=True (coil 66)
mock_instance.write_coils.assert_awaited_once()
assert mock_instance.write_coils.call_args.args == (65, [False, True])


@pytest.mark.asyncio
Expand Down
83 changes: 83 additions & 0 deletions tests/test_client_read_all.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""read_all_* helpers must match per-entity reads while batching requests."""

from unittest.mock import AsyncMock, MagicMock

import pytest

from python_qube_heatpump import QubeClient
from python_qube_heatpump.entities import BINARY_SENSORS, SENSORS, SWITCHES


def _fake_device(mock_instance):
"""Serve a fixed, address-dependent register and bit map."""

def registers(address, count=1, **kwargs):
resp = MagicMock()
resp.isError.return_value = False
# Varied words so float32/int16/uint32 decoding is exercised
resp.registers = [
(a * 7919 + 0x4100) & 0xFFFF for a in range(address, address + count)
]
return resp

def bits(address, count=1, **kwargs):
resp = MagicMock()
resp.isError.return_value = False
resp.bits = [
bool((a * 2654435761) & 4) for a in range(address, address + count)
]
return resp

mock_instance.read_input_registers = AsyncMock(side_effect=registers)
mock_instance.read_holding_registers = AsyncMock(side_effect=registers)
mock_instance.read_coils = AsyncMock(side_effect=bits)
mock_instance.read_discrete_inputs = AsyncMock(side_effect=bits)


def _total_reads(mock_instance) -> int:
return sum(
getattr(mock_instance, name).call_count
for name in (
"read_coils",
"read_discrete_inputs",
"read_input_registers",
"read_holding_registers",
)
)


def _same(a, b) -> bool:
if isinstance(a, float) and isinstance(b, float) and a != a and b != b:
return True # both NaN
return a == b and type(a) is type(b)


@pytest.mark.asyncio
@pytest.mark.parametrize(
("method", "table"),
[
("read_all_sensors", SENSORS),
("read_all_binary_sensors", BINARY_SENSORS),
("read_all_switches", SWITCHES),
],
)
async def test_read_all_matches_single_reads_with_fewer_requests(
mock_modbus_client, method, table
):
client = QubeClient("1.2.3.4", 502)
mock_instance = mock_modbus_client.return_value
client._client = mock_instance
_fake_device(mock_instance)

expected = {key: await client.read_entity(ent) for key, ent in table.items()}
single_reads = _total_reads(mock_instance)

mock_instance.reset_mock()
_fake_device(mock_instance)
result = await getattr(client, method)()

assert list(result) == list(table)
mismatched = [k for k in table if not _same(result[k], expected[k])]
assert mismatched == []
assert _total_reads(mock_instance) < single_reads
assert _total_reads(mock_instance) == len(client._plan_blocks(table.values()))
Loading
Loading