Skip to content

Commit 68f44c9

Browse files
3.4 (#51)
1 parent 21c7641 commit 68f44c9

File tree

15 files changed

+140
-33
lines changed

15 files changed

+140
-33
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ repos:
1414

1515

1616
- repo: https://github.com/astral-sh/ruff-pre-commit
17-
rev: v0.8.0
17+
rev: v0.8.1
1818
hooks:
1919
- id: ruff
2020
name: ruff unused imports

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
if not RTD_BUILD:
9090
nitpick_ignore_regex = [
9191
(re.compile(r'^py:data'), re.compile(r'typing\..+')),
92-
(re.compile(r'^py:class'), re.compile(r'pydantic_core\..+')),
92+
(re.compile(r'^py:class'), re.compile(r'(?:pydantic_core|pydantic)\..+')),
9393
# WARNING: py:class reference target not found: sml2mqtt.config.operations.Annotated
9494
(re.compile(r'^py:class'), re.compile(r'.+\.Annotated')),
9595
]

docs/index.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ from energy meters and report the values through mqtt.
77
The meters can be read through serial ports or through http (e.g. Tibber) and the values that
88
will be reported can be processed in various ways with operations.
99

10+
For reading through the serial port an USB to IR adapter is required.
11+
These are sometimes also called "Hichi" reader
12+
13+
1014
.. toctree::
1115
:maxdepth: 2
1216
:caption: Contents:

docs/operations.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,24 @@ Example
190190
round: 2
191191
192192
193+
Round To Multiple
194+
--------------------------------------
195+
196+
.. autopydantic_model:: RoundToMultiple
197+
:inherited-members: BaseModel
198+
199+
Example
200+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
201+
..
202+
YamlModel: RoundToMultiple
203+
204+
.. code-block:: yaml
205+
206+
type: round to multiple
207+
value: 20
208+
round: down
209+
210+
193211
Workarounds
194212
======================================
195213

readme.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ To read from the serial port an IR to USB reader for energy meter is required.
2121

2222
# Changelog
2323

24+
#### 3.4 (2024-12-03)
25+
- Allow rounding to the multiple of a value
26+
- Updated dependencies
27+
2428
#### 3.3 (2024-11-26)
2529
- Updated dependencies and docs
2630
- Allow rounding to the tenth

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
-r requirements_setup.txt
22

33
# Testing
4-
pytest == 8.3.3
4+
pytest == 8.3.4
55
pre-commit == 4.0.1
66
pytest-asyncio == 0.24.0
77
aioresponses == 0.7.7
88

99
# Linter
10-
ruff == 0.8.0
10+
ruff == 0.8.1

requirements_setup.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
aiomqtt == 2.3.0
22
pyserial-asyncio == 0.6
33
easyconfig == 0.3.2
4-
pydantic == 2.8.2
4+
pydantic == 2.10.2
55
smllib == 1.5
6-
aiohttp == 3.11.7
6+
aiohttp == 3.11.9

src/sml2mqtt/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '3.3'
1+
__version__ = '3.4'

src/sml2mqtt/config/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Annotated
2+
13
from easyconfig import AppBaseModel, BaseModel, create_app_config
24
from pydantic import Field
35

@@ -42,7 +44,7 @@ class Settings(AppBaseModel):
4244
logging: LoggingSettings = Field(default_factory=LoggingSettings)
4345
mqtt: MqttConfig = Field(default_factory=MqttConfig)
4446
general: GeneralSettings = Field(default_factory=GeneralSettings)
45-
inputs: list[HttpSourceSettings | SerialSourceSettings] = Field(default_factory=list, discriminator='type')
47+
inputs: list[Annotated[HttpSourceSettings | SerialSourceSettings, Field(discriminator='type')]] = []
4648
devices: dict[LowerStr, SmlDeviceConfig] = Field({}, description='Device configuration by ID or url',)
4749

4850

src/sml2mqtt/config/operations.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,15 @@ class Round(BaseModel):
112112
digits: int = Field(ge=-4, le=6, alias='round', description='Round to the specified digits, negative for tens')
113113

114114

115+
class RoundToMultiple(BaseModel):
116+
"""Rounds to the multiple of a given value.
117+
"""
118+
119+
type: Literal['round to multiple']
120+
value: int = Field(description='Round to the multiple of the given value')
121+
round: Literal['up', 'down', 'nearest'] = Field(description='Round up, down or to the nearset multiple')
122+
123+
115124
# -------------------------------------------------------------------------------------------------
116125
# Workarounds
117126
# -------------------------------------------------------------------------------------------------
@@ -264,7 +273,7 @@ class MeanOfInterval(HasIntervalFields):
264273
OperationsModels = (
265274
OnChangeFilter, DeltaFilter, HeartbeatAction, RangeFilter,
266275
RefreshAction, ThrottleFilter,
267-
Factor, Offset, Round,
276+
Factor, Offset, Round, RoundToMultiple,
268277
NegativeOnEnergyMeterWorkaround,
269278
Or, Sequence,
270279
VirtualMeter, MaxValue, MinValue,

src/sml2mqtt/sml_value/operations/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
SkipZeroMeterOperation,
88
ThrottleFilterOperation,
99
)
10-
from .math import FactorOperation, OffsetOperation, RoundOperation
10+
from .math import FactorOperation, OffsetOperation, RoundOperation, RoundToMultipleOperation
1111
from .operations import OrOperation, SequenceOperation
1212
from .time_series import MaxOfIntervalOperation, MeanOfIntervalOperation, MinOfIntervalOperation
1313
from .workarounds import NegativeOnEnergyMeterWorkaroundOperation

src/sml2mqtt/sml_value/operations/math.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections.abc import Generator
2-
from typing import Final
2+
from math import ceil, floor
3+
from typing import Final, Literal
34

45
from typing_extensions import override
56

@@ -51,8 +52,6 @@ def process_value(self, value: float | None, info: SmlValueInfo) -> float | None
5152
if value is None:
5253
return None
5354

54-
if isinstance(value, int):
55-
return value
5655
return round(value, self.digits)
5756

5857
def __repr__(self) -> str:
@@ -61,3 +60,46 @@ def __repr__(self) -> str:
6160
@override
6261
def describe(self, indent: str = '') -> Generator[str, None, None]:
6362
yield f'{indent:s}- Round: {self.digits if self.digits is not None else "integer"}'
63+
64+
65+
class RoundToMultipleOperation(ValueOperationBase):
66+
# noinspection PyShadowingBuiltins
67+
def __init__(self, value: int, round: Literal['up', 'down', 'nearest']) -> None: # noqa: A002
68+
self.multiple: Final = value
69+
70+
self.round_up: Final = round == 'up'
71+
self.round_down: Final = round == 'down'
72+
73+
@override
74+
def process_value(self, value: float | None, info: SmlValueInfo) -> float | None:
75+
if value is None:
76+
return None
77+
78+
if self.round_up:
79+
return self.multiple * int(ceil(value / self.multiple))
80+
if self.round_down:
81+
return self.multiple * int(floor(value / self.multiple))
82+
83+
multiple = self.multiple
84+
div, rest = divmod(value, multiple)
85+
div = int(div)
86+
87+
if rest >= 0.5 * multiple:
88+
return (div + 1) * multiple
89+
return div * multiple
90+
91+
def __mode_str(self) -> str:
92+
if self.round_up:
93+
return 'up'
94+
if self.round_down:
95+
return 'down'
96+
return 'nearest'
97+
98+
def __repr__(self) -> str:
99+
return f'<RoundToMultiple: value={self.multiple} round={self.__mode_str()} at 0x{id(self):x}>'
100+
101+
@override
102+
def describe(self, indent: str = '') -> Generator[str, None, None]:
103+
yield f'{indent:s}- Round To Multiple:'
104+
yield f'{indent:s} value: {self.multiple}'
105+
yield f'{indent:s} round: {self.__mode_str()}'

src/sml2mqtt/sml_value/setup_operations.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
RangeFilter,
2121
RefreshAction,
2222
Round,
23+
RoundToMultiple,
2324
Sequence,
2425
ThrottleFilter,
2526
VirtualMeter,
@@ -41,6 +42,7 @@
4142
RangeFilterOperation,
4243
RefreshActionOperation,
4344
RoundOperation,
45+
RoundToMultipleOperation,
4446
SequenceOperation,
4547
ThrottleFilterOperation,
4648
VirtualMeterOperation,
@@ -67,14 +69,15 @@ def create_sequence(operations: list[OperationsType]):
6769
OnChangeFilter: OnChangeFilterOperation,
6870
HeartbeatAction: HeartbeatActionOperation,
6971
DeltaFilter: DeltaFilterOperation,
72+
RangeFilter: RangeFilterOperation,
7073

7174
RefreshAction: RefreshActionOperation,
7275
ThrottleFilter: ThrottleFilterOperation,
7376

7477
Factor: FactorOperation,
7578
Offset: OffsetOperation,
7679
Round: RoundOperation,
77-
RangeFilter: RangeFilterOperation,
80+
RoundToMultiple: RoundToMultipleOperation,
7881

7982
NegativeOnEnergyMeterWorkaround: create_workaround_negative_on_energy_meter,
8083

tests/sml_values/test_operations/test_math.py

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,68 @@
11
from tests.sml_values.test_operations.helper import check_description, check_operation_repr
22

3+
from sml2mqtt.sml_value.base import ValueOperationBase
34
from sml2mqtt.sml_value.operations import (
45
FactorOperation,
56
OffsetOperation,
67
RoundOperation,
8+
RoundToMultipleOperation,
79
)
810

911

12+
def check_values(o: ValueOperationBase, *values: tuple[float, float]) -> None:
13+
assert o.process_value(None, None) is None
14+
15+
for _in, _out in values:
16+
_res = o.process_value(_in, None)
17+
assert _res == _out, f'in: {_in} out: {_res} expected: {_out}'
18+
assert isinstance(_res, type(_out))
19+
20+
1021
def test_factor() -> None:
1122
o = FactorOperation(5)
1223
check_operation_repr(o, '5')
1324

14-
assert o.process_value(None, None) is None
15-
assert o.process_value(5, None) == 25
16-
assert o.process_value(1.25, None) == 6.25
17-
assert o.process_value(-3, None) == -15
25+
check_values(o, (5, 25), (1.25, 6.25), (-3, -15))
1826

1927

2028
def test_offset() -> None:
2129
o = OffsetOperation(-5)
2230
check_operation_repr(o, '-5')
2331

24-
assert o.process_value(None, None) is None
25-
assert o.process_value(5, None) == 0
26-
assert o.process_value(1.25, None) == -3.75
27-
assert o.process_value(-3, None) == -8
32+
check_values(o, (5, 0), (1.25, -3.75), (-3, -8))
2833

2934

3035
def test_round() -> None:
3136
o = RoundOperation(0)
3237
check_operation_repr(o, '0')
33-
34-
assert o.process_value(None, None) is None
35-
assert o.process_value(5, None) == 5
36-
assert o.process_value(1.25, None) == 1
37-
assert o.process_value(-3, None) == -3
38-
assert o.process_value(-3.65, None) == -4
38+
check_values(o, (5, 5), (1.25, 1), (-3, -3), (-3.65, -4))
3939

4040
o = RoundOperation(1)
4141
check_operation_repr(o, '1')
42+
check_values(o, (5, 5), (1.25, 1.2), (-3, -3), (-3.65, -3.6))
43+
44+
o = RoundOperation(-1)
45+
check_operation_repr(o, '-1')
46+
check_values(o, (5, 0), (6, 10), (-5, 0), (-6, -10))
4247

43-
assert o.process_value(None, None) is None
44-
assert o.process_value(5, None) == 5
45-
assert o.process_value(1.25, None) == 1.2
46-
assert o.process_value(-3, None) == -3
47-
assert o.process_value(-3.65, None) == -3.6
48+
49+
def test_round_to_value() -> None:
50+
o = RoundToMultipleOperation(20, 'up')
51+
check_operation_repr(o, 'value=20 round=up')
52+
check_values(
53+
o, (0, 0), (0.0001, 20), (20, 20), (39.999999, 40), (40, 40), (40.000001, 60), (-20, -20), (-20.000001, -20))
54+
55+
o = RoundToMultipleOperation(20, 'down')
56+
check_operation_repr(o, 'value=20 round=down')
57+
check_values(
58+
o, (0, 0), (0.0001, 0), (20, 20), (39.999999, 20), (40, 40), (40.000001, 40), (-20, -20), (-20.000001, -40))
59+
60+
o = RoundToMultipleOperation(20, 'nearest')
61+
check_operation_repr(o, 'value=20 round=nearest')
62+
check_values(
63+
o, (0, 0), (9.9999, 0), (10, 20), (29.999999, 20), (30, 40), (40.000001, 40), (-20, -20),
64+
(-30, -20), (-30.1, -40)
65+
)
4866

4967

5068
def test_description() -> None:
@@ -77,3 +95,9 @@ def test_description() -> None:
7795
RoundOperation(1),
7896
'- Round: 1'
7997
)
98+
99+
for mode in 'up', 'down', 'nearest':
100+
check_description(
101+
RoundToMultipleOperation(50, mode),
102+
['- Round To Multiple:', ' value: 50', f' round: {mode:s}']
103+
)

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ commands =
4747

4848
[pytest]
4949
asyncio_mode = auto
50+
asyncio_default_fixture_loop_scope = function
5051

5152
markers =
5253
ignore_log_errors: Ignore logged errors

0 commit comments

Comments
 (0)