Skip to content

Commit 46b8a0c

Browse files
authored
Merge pull request #247 from plugwise/enable_prod_2
Implement setting of energy logging intervals
2 parents 497ee31 + a969a31 commit 46b8a0c

File tree

9 files changed

+142
-46
lines changed

9 files changed

+142
-46
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Changelog
22

3-
## v0.40.1
3+
## v0.41.0
4+
5+
- Implement setting of energy logging intervals [#247](https://github.com/plugwise/python-plugwise-usb/pull/247)
6+
7+
## v0.40.1 (not released)
48

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

plugwise_usb/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,16 @@ async def set_accept_join_request(self, state: bool) -> bool:
210210
raise NodeError(f"Failed setting accept joining: {exc}") from exc
211211
return True
212212

213+
async def set_energy_intervals(
214+
self, mac: str, cons_interval: int, prod_interval: int
215+
) -> bool:
216+
"""Configure the energy logging interval settings."""
217+
try:
218+
await self._network.set_energy_intervals(mac, cons_interval, prod_interval)
219+
except (MessageError, NodeError, ValueError) as exc:
220+
raise NodeError(f"{exc}") from exc
221+
return True
222+
213223
async def clear_cache(self) -> None:
214224
"""Clear current cache."""
215225
if self._network is not None:

plugwise_usb/messages/requests.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1264,7 +1264,7 @@ class CircleMeasureIntervalRequest(PlugwiseRequest):
12641264
12651265
FIXME: Make sure production interval is a multiply of consumption !!
12661266
1267-
Response message: Ack message with ??? TODO:
1267+
Response message: NodeResponse with ack-type POWER_LOG_INTERVAL_ACCEPTED
12681268
"""
12691269

12701270
_identifier = b"0057"
@@ -1281,6 +1281,17 @@ def __init__(
12811281
self._args.append(Int(consumption, length=4))
12821282
self._args.append(Int(production, length=4))
12831283

1284+
async def send(self) -> NodeResponse | None:
1285+
"""Send request."""
1286+
result = await self._send_request()
1287+
if isinstance(result, NodeResponse):
1288+
return result
1289+
if result is None:
1290+
return None
1291+
raise MessageError(
1292+
f"Invalid response message. Received {result.__class__.__name__}, expected NodeResponse"
1293+
)
1294+
12841295

12851296
class NodeClearGroupMacRequest(PlugwiseRequest):
12861297
"""TODO: usage?.

plugwise_usb/messages/responses.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ class StickResponseType(bytes, Enum):
5656
class NodeResponseType(bytes, Enum):
5757
"""Response types of a 'NodeResponse' reply message."""
5858

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

6970
# TODO: Validate these response types
7071
SED_CONFIG_FAILED = b"00F7"
71-
POWER_LOG_INTERVAL_ACCEPTED = b"00F8"
7272
POWER_CALIBRATION_ACCEPTED = b"00DA"
7373

7474

plugwise_usb/network/__init__.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
from ..connection import StickController
1515
from ..constants import UTF8
1616
from ..exceptions import CacheError, MessageError, NodeError, StickError, StickTimeout
17-
from ..messages.requests import CirclePlusAllowJoiningRequest, NodePingRequest
17+
from ..messages.requests import (
18+
CirclePlusAllowJoiningRequest,
19+
CircleMeasureIntervalRequest,
20+
NodePingRequest,
21+
)
1822
from ..messages.responses import (
1923
NODE_AWAKE_RESPONSE_ID,
2024
NODE_JOIN_ID,
@@ -537,6 +541,34 @@ async def allow_join_requests(self, state: bool) -> None:
537541
_LOGGER.debug("Sent AllowJoiningRequest to Circle+ with state=%s", state)
538542
self.accept_join_request = state
539543

544+
async def set_energy_intervals(
545+
self, mac: str, consumption: int, production: int
546+
) -> None:
547+
"""Set the logging intervals for both energy consumption and production.
548+
549+
Default: consumption = 60, production = 0.
550+
For logging energy in both directions set both to 60.
551+
"""
552+
# Validate input parameters
553+
if consumption <= 0:
554+
raise ValueError("Consumption interval must be positive")
555+
if production < 0:
556+
raise ValueError("Production interval must be non-negative")
557+
if production > 0 and production % consumption != 0:
558+
raise ValueError("Production interval must be a multiple of consumption interval")
559+
560+
_LOGGER.debug("set_energy_intervals | cons=%s, prod=%s", consumption, production)
561+
request = CircleMeasureIntervalRequest(
562+
self._controller.send, bytes(mac, UTF8), consumption, production
563+
)
564+
if (response := await request.send()) is None:
565+
raise NodeError("No response for CircleMeasureIntervalRequest.")
566+
567+
if response.response_type != NodeResponseType.POWER_LOG_INTERVAL_ACCEPTED:
568+
raise MessageError(
569+
f"Unknown NodeResponseType '{response.response_type.name}' received"
570+
)
571+
540572
def subscribe_to_node_events(
541573
self,
542574
node_event_callback: Callable[[NodeEvent, str], Coroutine[Any, Any, None]],

plugwise_usb/nodes/helpers/pulses.py

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ def __init__(self, mac: str) -> None:
8888
self._rollover_consumption = False
8989
self._rollover_production = False
9090

91+
self._first_next_log_processed = False
92+
self._first_prev_log_processed = False
9193
self._logs: dict[int, dict[int, PulseLogRecord]] | None = None
9294
self._log_addresses_missing: list[int] | None = None
9395
self._log_production: bool | None = None
@@ -377,6 +379,8 @@ def _detect_rollover(
377379
)
378380
return False
379381

382+
return False
383+
380384
def add_empty_log(self, address: int, slot: int) -> None:
381385
"""Add empty energy log record to mark any start of beginning of energy log collection."""
382386
recalculate = False
@@ -439,11 +443,12 @@ def add_log(
439443
self.recalculate_missing_log_addresses()
440444

441445
_LOGGER.debug(
442-
"add_log | pulses=%s | address=%s | slot= %s |time:%s",
446+
"add_log | pulses=%s | address=%s | slot=%s | time=%s, direction=%s",
443447
pulses,
444448
address,
445449
slot,
446450
timestamp,
451+
direction,
447452
)
448453
return True
449454

@@ -504,37 +509,70 @@ def _update_log_direction(
504509
if self._logs is None:
505510
return
506511

512+
prev_timestamp = self._check_prev_production(address, slot, timestamp)
513+
next_timestamp = self._check_next_production(address, slot, timestamp)
514+
if self._first_prev_log_processed and self._first_next_log_processed:
515+
# _log_production is True when 2 out of 3 consecutive slots have
516+
# the same timestamp, otherwise it is False
517+
self._log_production = (
518+
next_timestamp == timestamp and prev_timestamp != timestamp
519+
) or (next_timestamp == prev_timestamp and next_timestamp != timestamp)
520+
521+
def _check_prev_production(
522+
self, address: int, slot: int, timestamp: datetime
523+
) -> datetime | None:
524+
"""Check the previous slot for production pulses."""
507525
prev_address, prev_slot = calc_log_address(address, slot, -1)
508526
if self._log_exists(prev_address, prev_slot):
509-
if self._logs[prev_address][prev_slot].timestamp == timestamp:
510-
# Given log is the second log with same timestamp,
511-
# mark direction as production
512-
self._logs[address][slot].is_consumption = False
513-
self._logs[prev_address][prev_slot].is_consumption = True
514-
self._log_production = True
515-
elif self._log_production:
516-
self._logs[address][slot].is_consumption = True
517-
if self._logs[prev_address][prev_slot].is_consumption:
518-
self._logs[prev_address][prev_slot].is_consumption = False
519-
self._reset_log_references()
520-
elif self._log_production is None:
521-
self._log_production = False
527+
prev_timestamp = self._logs[prev_address][prev_slot].timestamp
528+
if not self._first_prev_log_processed:
529+
self._first_prev_log_processed = True
530+
if prev_timestamp == timestamp:
531+
# Given log is the second log with same timestamp,
532+
# mark direction as production
533+
self._logs[address][slot].is_consumption = False
534+
self._logs[prev_address][prev_slot].is_consumption = True
535+
self._log_production = True
536+
elif self._log_production:
537+
self._logs[address][slot].is_consumption = True
538+
if self._logs[prev_address][prev_slot].is_consumption:
539+
self._logs[prev_address][prev_slot].is_consumption = False
540+
self._reset_log_references()
541+
elif self._log_production is None:
542+
self._log_production = False
543+
return prev_timestamp
544+
545+
if self._first_prev_log_processed:
546+
self._first_prev_log_processed = False
547+
return None
522548

549+
def _check_next_production(
550+
self, address: int, slot: int, timestamp: datetime
551+
) -> datetime | None:
552+
"""Check the next slot for production pulses."""
523553
next_address, next_slot = calc_log_address(address, slot, 1)
524554
if self._log_exists(next_address, next_slot):
525-
if self._logs[next_address][next_slot].timestamp == timestamp:
526-
# Given log is the first log with same timestamp,
527-
# mark direction as production of next log
528-
self._logs[address][slot].is_consumption = True
529-
if self._logs[next_address][next_slot].is_consumption:
530-
self._logs[next_address][next_slot].is_consumption = False
531-
self._reset_log_references()
532-
self._log_production = True
533-
elif self._log_production:
534-
self._logs[address][slot].is_consumption = False
535-
self._logs[next_address][next_slot].is_consumption = True
536-
elif self._log_production is None:
537-
self._log_production = False
555+
next_timestamp = self._logs[next_address][next_slot].timestamp
556+
if not self._first_next_log_processed:
557+
self._first_next_log_processed = True
558+
if next_timestamp == timestamp:
559+
# Given log is the first log with same timestamp,
560+
# mark direction as production of next log
561+
self._logs[address][slot].is_consumption = True
562+
if self._logs[next_address][next_slot].is_consumption:
563+
self._logs[next_address][next_slot].is_consumption = False
564+
self._reset_log_references()
565+
self._log_production = True
566+
elif self._log_production:
567+
self._logs[address][slot].is_consumption = False
568+
self._logs[next_address][next_slot].is_consumption = True
569+
elif self._log_production is None:
570+
self._log_production = False
571+
return next_timestamp
572+
573+
if self._first_next_log_processed:
574+
self._first_next_log_processed = False
575+
return None
538576

539577
def _update_log_interval(self) -> None:
540578
"""Update the detected log interval based on the most recent two logs."""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "plugwise_usb"
7-
version = "v0.40.1b1"
7+
version = "v0.41.0"
88
license = "MIT"
99
keywords = ["home", "automation", "plugwise", "module", "usb"]
1010
classifiers = [

scripts/tests_and_coverage.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ set +u
2323

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

2930
if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "linting" ] ; then

tests/test_usb.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,15 +1218,15 @@ def test_pulse_collection_production(self, monkeypatch: pytest.MonkeyPatch) -> N
12181218
# Test consumption & production - Log import #1 - production
12191219
# Missing addresses can not be determined yet
12201220
test_timestamp = fixed_this_hour - td(hours=1)
1221-
tst_production.add_log(200, 2, test_timestamp, 2000)
1221+
tst_production.add_log(200, 2, test_timestamp, -2000)
12221222
assert tst_production.log_addresses_missing is None
12231223
assert tst_production.production_logging is None
12241224

12251225
# Test consumption & production - Log import #2 - consumption
12261226
# production must be enabled & intervals are unknown
12271227
# Log at address 200 is known and expect production logs too
12281228
test_timestamp = fixed_this_hour - td(hours=1)
1229-
tst_production.add_log(200, 1, test_timestamp, 1000)
1229+
tst_production.add_log(200, 1, test_timestamp, 0)
12301230
assert tst_production.log_addresses_missing is None
12311231
assert tst_production.log_interval_consumption is None
12321232
assert tst_production.log_interval_production is None
@@ -1235,7 +1235,7 @@ def test_pulse_collection_production(self, monkeypatch: pytest.MonkeyPatch) -> N
12351235
# Test consumption & production - Log import #3 - production
12361236
# Interval of consumption is not yet available
12371237
test_timestamp = fixed_this_hour - td(hours=2) # type: ignore[unreachable]
1238-
tst_production.add_log(199, 4, test_timestamp, 4000)
1238+
tst_production.add_log(199, 4, test_timestamp, -2200)
12391239
missing_check = list(range(199, 157, -1))
12401240
assert tst_production.log_addresses_missing == missing_check
12411241
assert tst_production.log_interval_consumption is None
@@ -1245,32 +1245,32 @@ def test_pulse_collection_production(self, monkeypatch: pytest.MonkeyPatch) -> N
12451245
# Test consumption & production - Log import #4
12461246
# Interval of consumption is available
12471247
test_timestamp = fixed_this_hour - td(hours=2)
1248-
tst_production.add_log(199, 3, test_timestamp, 3000)
1248+
tst_production.add_log(199, 3, test_timestamp, 0)
12491249
assert tst_production.log_addresses_missing == missing_check
12501250
assert tst_production.log_interval_consumption == 60
12511251
assert tst_production.log_interval_production == 60
12521252
assert tst_production.production_logging
12531253

12541254
pulse_update_1 = fixed_this_hour + td(minutes=5)
1255-
tst_production.update_pulse_counter(100, 50, pulse_update_1)
1255+
tst_production.update_pulse_counter(0, -500, pulse_update_1)
12561256
assert tst_production.collected_pulses(
12571257
fixed_this_hour, is_consumption=True
1258-
) == (100, pulse_update_1)
1258+
) == (0, pulse_update_1)
12591259
assert tst_production.collected_pulses(
12601260
fixed_this_hour, is_consumption=False
1261-
) == (50, pulse_update_1)
1261+
) == (500, pulse_update_1)
12621262
assert tst_production.collected_pulses(
12631263
fixed_this_hour - td(hours=1), is_consumption=True
1264-
) == (100, pulse_update_1)
1264+
) == (0, pulse_update_1)
12651265
assert tst_production.collected_pulses(
12661266
fixed_this_hour - td(hours=2), is_consumption=True
1267-
) == (1000 + 100, pulse_update_1)
1267+
) == (0 + 0, pulse_update_1)
12681268
assert tst_production.collected_pulses(
12691269
fixed_this_hour - td(hours=1), is_consumption=False
1270-
) == (50, pulse_update_1)
1270+
) == (500, pulse_update_1)
12711271
assert tst_production.collected_pulses(
12721272
fixed_this_hour - td(hours=2), is_consumption=False
1273-
) == (2000 + 50, pulse_update_1)
1273+
) == (2000 + 500, pulse_update_1)
12741274

12751275
_pulse_update = 0
12761276

0 commit comments

Comments
 (0)