Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f556da3
Network: add set_measure_interval()
bouwew May 26, 2025
0c5a225
Create corresponding function at __init__-level
bouwew May 26, 2025
215fdbe
Fix missing send-callback
bouwew May 26, 2025
1d4e5aa
Add missing mac function-property
bouwew May 26, 2025
e38ef99
Add send-function to CircleMeasureIntervalRequest
bouwew May 26, 2025
cb15700
Improve response processing
bouwew May 26, 2025
820ea10
Fix ident
bouwew May 26, 2025
2dc80a0
Bump to v0.41.0a0 test-version
bouwew May 26, 2025
6c784ea
Pass mac as bytes
bouwew May 26, 2025
baf59d1
Bump to a1
bouwew May 26, 2025
c2e552e
Finish set_measurement_interval() function
bouwew May 26, 2025
bc2af7e
Update __ini__ - set_measure_interval()
bouwew May 26, 2025
826d315
Bump to a2
bouwew May 26, 2025
76b50cd
Update NodeResponseType list
bouwew May 26, 2025
e6b2225
Use a 2nd previous slot timestamp to detect returning to consumption …
bouwew May 27, 2025
ecfad81
Move code-block up
bouwew May 27, 2025
2fd0337
Fix ident
bouwew May 27, 2025
354e93e
Add comment, clean up
bouwew May 27, 2025
78d57b2
Add debug logging
bouwew May 27, 2025
38f41fb
Next try
bouwew May 28, 2025
500c944
Full test-output
bouwew May 28, 2025
0959232
Add direction to add_log() debug-logging
bouwew May 28, 2025
1aa1573
Test: change to real production-numbers
bouwew May 28, 2025
e49ce38
Bump to a3
bouwew May 28, 2025
0a76cea
Improve
bouwew May 28, 2025
1972de8
Bump to a4
bouwew May 28, 2025
4b42a47
Correct function-names
bouwew May 30, 2025
6efaa30
Remove debug-logging used for testing
bouwew May 30, 2025
590b576
Improve function-names
bouwew May 30, 2025
d2be244
Fix
bouwew May 30, 2025
e886907
Add input values checking, as suggested
bouwew May 30, 2025
c611d65
Remove blank space
bouwew May 30, 2025
75aef63
Remove more blank spaces
bouwew May 30, 2025
9f84d62
Update CHANGELOG
bouwew May 30, 2025
eb9cbe5
Set to v0.41.0 release-version
bouwew May 30, 2025
ef9fe05
Remove more blank spaces
bouwew May 30, 2025
60efec5
Reduce complexity
bouwew May 30, 2025
d9aa981
Fix
bouwew May 30, 2025
b46d61b
Revert return deletion
bouwew May 30, 2025
a969a31
Update CHANGELOG
bouwew May 31, 2025
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Changelog

## v0.40.1
## v0.41.0

- Implement setting of energy logging intervals [#247](https://github.com/plugwise/python-plugwise-usb/pull/247)

## v0.40.1 (not released)

- Improve device Name and Model detection for Switch [#248](https://github.com/plugwise/python-plugwise-usb/pull/248)

Expand Down
10 changes: 10 additions & 0 deletions plugwise_usb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,16 @@
raise NodeError(f"Failed setting accept joining: {exc}") from exc
return True

async def set_energy_intervals(
self, mac: str, cons_interval: int, prod_interval: int
) -> bool:
"""Configure the energy logging interval settings."""
try:
await self._network.set_energy_intervals(mac, cons_interval, prod_interval)
except (MessageError, NodeError, ValueError) as exc:
raise NodeError(f"{exc}") from exc
return True

Check warning on line 221 in plugwise_usb/__init__.py

View check run for this annotation

Codecov / codecov/patch

plugwise_usb/__init__.py#L217-L221

Added lines #L217 - L221 were not covered by tests

async def clear_cache(self) -> None:
"""Clear current cache."""
if self._network is not None:
Expand Down
13 changes: 12 additions & 1 deletion plugwise_usb/messages/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1264,7 +1264,7 @@

FIXME: Make sure production interval is a multiply of consumption !!

Response message: Ack message with ??? TODO:
Response message: NodeResponse with ack-type POWER_LOG_INTERVAL_ACCEPTED
"""

_identifier = b"0057"
Expand All @@ -1281,6 +1281,17 @@
self._args.append(Int(consumption, length=4))
self._args.append(Int(production, length=4))

async def send(self) -> NodeResponse | None:
"""Send request."""
result = await self._send_request()
if isinstance(result, NodeResponse):
return result
if result is None:
return None
raise MessageError(

Check warning on line 1291 in plugwise_usb/messages/requests.py

View check run for this annotation

Codecov / codecov/patch

plugwise_usb/messages/requests.py#L1286-L1291

Added lines #L1286 - L1291 were not covered by tests
f"Invalid response message. Received {result.__class__.__name__}, expected NodeResponse"
)


class NodeClearGroupMacRequest(PlugwiseRequest):
"""TODO: usage?.
Expand Down
6 changes: 3 additions & 3 deletions plugwise_usb/messages/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ class StickResponseType(bytes, Enum):
class NodeResponseType(bytes, Enum):
"""Response types of a 'NodeResponse' reply message."""

CIRCLE_PLUS = b"00DD" # type for CirclePlusAllowJoiningRequest with state false
CIRCLE_PLUS = b"00DD" # ack for CirclePlusAllowJoiningRequest with state false
CLOCK_ACCEPTED = b"00D7"
JOIN_ACCEPTED = b"00D9" # type for CirclePlusAllowJoiningRequest with state true
JOIN_ACCEPTED = b"00D9" # ack for CirclePlusAllowJoiningRequest with state true
POWER_LOG_INTERVAL_ACCEPTED = b"00F8" # ack for CircleMeasureIntervalRequest
RELAY_SWITCHED_OFF = b"00DE"
RELAY_SWITCHED_ON = b"00D8"
RELAY_SWITCH_FAILED = b"00E2"
Expand All @@ -68,7 +69,6 @@ class NodeResponseType(bytes, Enum):

# TODO: Validate these response types
SED_CONFIG_FAILED = b"00F7"
POWER_LOG_INTERVAL_ACCEPTED = b"00F8"
POWER_CALIBRATION_ACCEPTED = b"00DA"


Expand Down
34 changes: 33 additions & 1 deletion plugwise_usb/network/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
from ..connection import StickController
from ..constants import UTF8
from ..exceptions import CacheError, MessageError, NodeError, StickError, StickTimeout
from ..messages.requests import CirclePlusAllowJoiningRequest, NodePingRequest
from ..messages.requests import (
CirclePlusAllowJoiningRequest,
CircleMeasureIntervalRequest,
NodePingRequest,
)
from ..messages.responses import (
NODE_AWAKE_RESPONSE_ID,
NODE_JOIN_ID,
Expand Down Expand Up @@ -537,6 +541,34 @@
_LOGGER.debug("Sent AllowJoiningRequest to Circle+ with state=%s", state)
self.accept_join_request = state

async def set_energy_intervals(
self, mac: str, consumption: int, production: int
) -> None:
"""Set the logging intervals for both energy consumption and production.

Default: consumption = 60, production = 0.
For logging energy in both directions set both to 60.
"""
# Validate input parameters
if consumption <= 0:
raise ValueError("Consumption interval must be positive")
if production < 0:
raise ValueError("Production interval must be non-negative")
if production > 0 and production % consumption != 0:
raise ValueError("Production interval must be a multiple of consumption interval")

Check warning on line 558 in plugwise_usb/network/__init__.py

View check run for this annotation

Codecov / codecov/patch

plugwise_usb/network/__init__.py#L553-L558

Added lines #L553 - L558 were not covered by tests

_LOGGER.debug("set_energy_intervals | cons=%s, prod=%s", consumption, production)
request = CircleMeasureIntervalRequest(

Check warning on line 561 in plugwise_usb/network/__init__.py

View check run for this annotation

Codecov / codecov/patch

plugwise_usb/network/__init__.py#L560-L561

Added lines #L560 - L561 were not covered by tests
self._controller.send, bytes(mac, UTF8), consumption, production
)
if (response := await request.send()) is None:
raise NodeError("No response for CircleMeasureIntervalRequest.")

Check warning on line 565 in plugwise_usb/network/__init__.py

View check run for this annotation

Codecov / codecov/patch

plugwise_usb/network/__init__.py#L564-L565

Added lines #L564 - L565 were not covered by tests

if response.response_type != NodeResponseType.POWER_LOG_INTERVAL_ACCEPTED:
raise MessageError(

Check warning on line 568 in plugwise_usb/network/__init__.py

View check run for this annotation

Codecov / codecov/patch

plugwise_usb/network/__init__.py#L567-L568

Added lines #L567 - L568 were not covered by tests
f"Unknown NodeResponseType '{response.response_type.name}' received"
)

def subscribe_to_node_events(
self,
node_event_callback: Callable[[NodeEvent, str], Coroutine[Any, Any, None]],
Expand Down
92 changes: 65 additions & 27 deletions plugwise_usb/nodes/helpers/pulses.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@
self._rollover_consumption = False
self._rollover_production = False

self._first_next_log_processed = False
self._first_prev_log_processed = False
self._logs: dict[int, dict[int, PulseLogRecord]] | None = None
self._log_addresses_missing: list[int] | None = None
self._log_production: bool | None = None
Expand Down Expand Up @@ -377,6 +379,8 @@
)
return False

return False

def add_empty_log(self, address: int, slot: int) -> None:
"""Add empty energy log record to mark any start of beginning of energy log collection."""
recalculate = False
Expand Down Expand Up @@ -439,11 +443,12 @@
self.recalculate_missing_log_addresses()

_LOGGER.debug(
"add_log | pulses=%s | address=%s | slot= %s |time:%s",
"add_log | pulses=%s | address=%s | slot=%s | time=%s, direction=%s",
pulses,
address,
slot,
timestamp,
direction,
)
return True

Expand Down Expand Up @@ -504,37 +509,70 @@
if self._logs is None:
return

prev_timestamp = self._check_prev_production(address, slot, timestamp)
next_timestamp = self._check_next_production(address, slot, timestamp)
if self._first_prev_log_processed and self._first_next_log_processed:
# _log_production is True when 2 out of 3 consecutive slots have
# the same timestamp, otherwise it is False
self._log_production = (
next_timestamp == timestamp and prev_timestamp != timestamp
) or (next_timestamp == prev_timestamp and next_timestamp != timestamp)

def _check_prev_production(
self, address: int, slot: int, timestamp: datetime
) -> datetime | None:
"""Check the previous slot for production pulses."""
prev_address, prev_slot = calc_log_address(address, slot, -1)
if self._log_exists(prev_address, prev_slot):
if self._logs[prev_address][prev_slot].timestamp == timestamp:
# Given log is the second log with same timestamp,
# mark direction as production
self._logs[address][slot].is_consumption = False
self._logs[prev_address][prev_slot].is_consumption = True
self._log_production = True
elif self._log_production:
self._logs[address][slot].is_consumption = True
if self._logs[prev_address][prev_slot].is_consumption:
self._logs[prev_address][prev_slot].is_consumption = False
self._reset_log_references()
elif self._log_production is None:
self._log_production = False
prev_timestamp = self._logs[prev_address][prev_slot].timestamp
if not self._first_prev_log_processed:
self._first_prev_log_processed = True
if prev_timestamp == timestamp:
# Given log is the second log with same timestamp,
# mark direction as production
self._logs[address][slot].is_consumption = False
self._logs[prev_address][prev_slot].is_consumption = True
self._log_production = True
elif self._log_production:
self._logs[address][slot].is_consumption = True
if self._logs[prev_address][prev_slot].is_consumption:
self._logs[prev_address][prev_slot].is_consumption = False
self._reset_log_references()

Check warning on line 540 in plugwise_usb/nodes/helpers/pulses.py

View check run for this annotation

Codecov / codecov/patch

plugwise_usb/nodes/helpers/pulses.py#L537-L540

Added lines #L537 - L540 were not covered by tests
elif self._log_production is None:
self._log_production = False

Check warning on line 542 in plugwise_usb/nodes/helpers/pulses.py

View check run for this annotation

Codecov / codecov/patch

plugwise_usb/nodes/helpers/pulses.py#L542

Added line #L542 was not covered by tests
return prev_timestamp

if self._first_prev_log_processed:
self._first_prev_log_processed = False
return None

def _check_next_production(
self, address: int, slot: int, timestamp: datetime
) -> datetime | None:
"""Check the next slot for production pulses."""
next_address, next_slot = calc_log_address(address, slot, 1)
if self._log_exists(next_address, next_slot):
if self._logs[next_address][next_slot].timestamp == timestamp:
# Given log is the first log with same timestamp,
# mark direction as production of next log
self._logs[address][slot].is_consumption = True
if self._logs[next_address][next_slot].is_consumption:
self._logs[next_address][next_slot].is_consumption = False
self._reset_log_references()
self._log_production = True
elif self._log_production:
self._logs[address][slot].is_consumption = False
self._logs[next_address][next_slot].is_consumption = True
elif self._log_production is None:
self._log_production = False
next_timestamp = self._logs[next_address][next_slot].timestamp
if not self._first_next_log_processed:
self._first_next_log_processed = True
if next_timestamp == timestamp:
# Given log is the first log with same timestamp,
# mark direction as production of next log
self._logs[address][slot].is_consumption = True
if self._logs[next_address][next_slot].is_consumption:
self._logs[next_address][next_slot].is_consumption = False
self._reset_log_references()
self._log_production = True
elif self._log_production:
self._logs[address][slot].is_consumption = False
self._logs[next_address][next_slot].is_consumption = True

Check warning on line 568 in plugwise_usb/nodes/helpers/pulses.py

View check run for this annotation

Codecov / codecov/patch

plugwise_usb/nodes/helpers/pulses.py#L567-L568

Added lines #L567 - L568 were not covered by tests
elif self._log_production is None:
self._log_production = False
return next_timestamp

if self._first_next_log_processed:
self._first_next_log_processed = False
return None

def _update_log_interval(self) -> None:
"""Update the detected log interval based on the most recent two logs."""
Expand Down
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 = "setuptools.build_meta"

[project]
name = "plugwise_usb"
version = "v0.40.1b1"
version = "v0.41.0"
license = "MIT"
keywords = ["home", "automation", "plugwise", "module", "usb"]
classifiers = [
Expand Down
3 changes: 2 additions & 1 deletion scripts/tests_and_coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ set +u

if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "test_and_coverage" ] ; then
# Python tests (rerun with debug if failures)
PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing || PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/
# PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing ||
PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/
fi

if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "linting" ] ; then
Expand Down
22 changes: 11 additions & 11 deletions tests/test_usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -1218,15 +1218,15 @@ def test_pulse_collection_production(self, monkeypatch: pytest.MonkeyPatch) -> N
# Test consumption & production - Log import #1 - production
# Missing addresses can not be determined yet
test_timestamp = fixed_this_hour - td(hours=1)
tst_production.add_log(200, 2, test_timestamp, 2000)
tst_production.add_log(200, 2, test_timestamp, -2000)
assert tst_production.log_addresses_missing is None
assert tst_production.production_logging is None

# Test consumption & production - Log import #2 - consumption
# production must be enabled & intervals are unknown
# Log at address 200 is known and expect production logs too
test_timestamp = fixed_this_hour - td(hours=1)
tst_production.add_log(200, 1, test_timestamp, 1000)
tst_production.add_log(200, 1, test_timestamp, 0)
assert tst_production.log_addresses_missing is None
assert tst_production.log_interval_consumption is None
assert tst_production.log_interval_production is None
Expand All @@ -1235,7 +1235,7 @@ def test_pulse_collection_production(self, monkeypatch: pytest.MonkeyPatch) -> N
# Test consumption & production - Log import #3 - production
# Interval of consumption is not yet available
test_timestamp = fixed_this_hour - td(hours=2) # type: ignore[unreachable]
tst_production.add_log(199, 4, test_timestamp, 4000)
tst_production.add_log(199, 4, test_timestamp, -2200)
missing_check = list(range(199, 157, -1))
assert tst_production.log_addresses_missing == missing_check
assert tst_production.log_interval_consumption is None
Expand All @@ -1245,32 +1245,32 @@ def test_pulse_collection_production(self, monkeypatch: pytest.MonkeyPatch) -> N
# Test consumption & production - Log import #4
# Interval of consumption is available
test_timestamp = fixed_this_hour - td(hours=2)
tst_production.add_log(199, 3, test_timestamp, 3000)
tst_production.add_log(199, 3, test_timestamp, 0)
assert tst_production.log_addresses_missing == missing_check
assert tst_production.log_interval_consumption == 60
assert tst_production.log_interval_production == 60
assert tst_production.production_logging

pulse_update_1 = fixed_this_hour + td(minutes=5)
tst_production.update_pulse_counter(100, 50, pulse_update_1)
tst_production.update_pulse_counter(0, -500, pulse_update_1)
assert tst_production.collected_pulses(
fixed_this_hour, is_consumption=True
) == (100, pulse_update_1)
) == (0, pulse_update_1)
assert tst_production.collected_pulses(
fixed_this_hour, is_consumption=False
) == (50, pulse_update_1)
) == (500, pulse_update_1)
assert tst_production.collected_pulses(
fixed_this_hour - td(hours=1), is_consumption=True
) == (100, pulse_update_1)
) == (0, pulse_update_1)
assert tst_production.collected_pulses(
fixed_this_hour - td(hours=2), is_consumption=True
) == (1000 + 100, pulse_update_1)
) == (0 + 0, pulse_update_1)
assert tst_production.collected_pulses(
fixed_this_hour - td(hours=1), is_consumption=False
) == (50, pulse_update_1)
) == (500, pulse_update_1)
assert tst_production.collected_pulses(
fixed_this_hour - td(hours=2), is_consumption=False
) == (2000 + 50, pulse_update_1)
) == (2000 + 500, pulse_update_1)

_pulse_update = 0

Expand Down
Loading