proxon-modbus 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- proxon_modbus-0.1.1/.gitignore +7 -0
- proxon_modbus-0.1.1/PKG-INFO +104 -0
- proxon_modbus-0.1.1/README.md +88 -0
- proxon_modbus-0.1.1/pyproject.toml +28 -0
- proxon_modbus-0.1.1/src/proxon_modbus/__init__.py +77 -0
- proxon_modbus-0.1.1/src/proxon_modbus/device.py +118 -0
- proxon_modbus-0.1.1/src/proxon_modbus/fields.py +130 -0
- proxon_modbus-0.1.1/src/proxon_modbus/fwt.py +174 -0
- proxon_modbus-0.1.1/src/proxon_modbus/nbe.py +84 -0
- proxon_modbus-0.1.1/src/proxon_modbus/t300.py +96 -0
- proxon_modbus-0.1.1/tests/test_device.py +112 -0
- proxon_modbus-0.1.1/tests/test_fwt.py +120 -0
- proxon_modbus-0.1.1/tests/test_nbe.py +104 -0
- proxon_modbus-0.1.1/tests/test_t300.py +96 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: proxon-modbus
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Gerätebibliothek für Proxon FWT 2.0 und T300 über Modbus RTU
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: modbus-connection>=4.10.0
|
|
8
|
+
Provides-Extra: pymodbus
|
|
9
|
+
Requires-Dist: modbus-connection[pymodbus]>=4.10.0; extra == 'pymodbus'
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
12
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
13
|
+
Provides-Extra: tmodbus
|
|
14
|
+
Requires-Dist: modbus-connection[tmodbus]>=4.10.0; extra == 'tmodbus'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# proxon-modbus
|
|
18
|
+
|
|
19
|
+
Gerätebibliothek für **Proxon FWT 2.0** (Lüftungs-Wärmepumpe) und **T300**
|
|
20
|
+
(Warmwasser-Wärmepumpe) über Modbus RTU.
|
|
21
|
+
|
|
22
|
+
Die Bibliothek besitzt den Transport **nicht**. Anwendungen reichen eine
|
|
23
|
+
`modbus_connection.ModbusUnit` herein — Backend `tmodbus` oder `pymodbus` —
|
|
24
|
+
und bekommen dafür das Registerwissen der Anlage. Damit lässt sich sie ohne
|
|
25
|
+
Hardware testen und teilt sich eine Verbindung mit anderen Verbrauchern
|
|
26
|
+
desselben RS485-Strangs.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from modbus_connection import ModbusTcpParams
|
|
30
|
+
from modbus_connection.tmodbus import ModbusConnection
|
|
31
|
+
from proxon_modbus import ProxonDevice
|
|
32
|
+
|
|
33
|
+
conn = ModbusConnection(
|
|
34
|
+
ModbusTcpParams(host="192.0.2.10", port=502, framer="rtu"),
|
|
35
|
+
message_spacing=0.15, # Pause zwischen Blöcken
|
|
36
|
+
connect_delay=0.30, # Wartezeit nach Verbindungsaufbau
|
|
37
|
+
)
|
|
38
|
+
device = ProxonDevice(conn.for_unit(41))
|
|
39
|
+
await device.async_update()
|
|
40
|
+
|
|
41
|
+
print(device.t300_input.t21_behaelter_mitte) # 57.5
|
|
42
|
+
print(device.fwt_holding.bypass_min_frischluft) # 8.0
|
|
43
|
+
print([d.temperatur for d in device.nbe_temperatures.devices])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Warum eigene Feldtypen
|
|
47
|
+
|
|
48
|
+
Zwei Eigenheiten der Anlage kann `modbus_connection` nicht von sich aus:
|
|
49
|
+
|
|
50
|
+
**Skalierung und Offset sind anders gemeint.** Proxon rechnet
|
|
51
|
+
`(roh + offset) / scale`, die Bibliothek `roh * scale + offset`; Proxons
|
|
52
|
+
`scale` ist ein Divisor, ihrer ein Multiplikator. `proxon_number()` nimmt die
|
|
53
|
+
Proxon-Zahlen entgegen und rechnet um — Registerzeilen lassen sich also 1:1
|
|
54
|
+
aus der Anlagendokumentation übernehmen. Ohne diese Umrechnung liest der
|
|
55
|
+
Behälterfühler T21 statt **57,5 °C** ganze **−842,5 °C**.
|
|
56
|
+
|
|
57
|
+
**Fremde Frames müssen verworfen werden.** Die USR-RS485-Bridge spiegelt den
|
|
58
|
+
gesamten Busverkehr in die TCP-Verbindung; einzelne Register enthalten dadurch
|
|
59
|
+
Werte anderer Geräte (klassisch: 128 °C Warmwasser). 46 der 94 Register tragen
|
|
60
|
+
deshalb einen gültigen Rohbereich. `nan=` der Bibliothek trifft nur einzelne
|
|
61
|
+
Werte, nicht Bereiche — `RangedNumberField` prüft `min_raw`/`max_raw` auf dem
|
|
62
|
+
vorzeichenbehafteten Rohwert und liefert sonst `None`.
|
|
63
|
+
|
|
64
|
+
Dazu kommt `Latin1StringField`: die Raumnamen liegen als zwei Latin-1-Bytes je
|
|
65
|
+
Register, das `StringField` der Bibliothek ist ASCII — „Küche" käme kaputt an.
|
|
66
|
+
|
|
67
|
+
## Aufbau
|
|
68
|
+
|
|
69
|
+
| Baugruppe | Register |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `FwtInput` | Input 0–51, 154–265 |
|
|
72
|
+
| `FwtHolding` | Holding 16–22, 41–143, 187, 438, 460, 467–469, 613–619 |
|
|
73
|
+
| `T300Input` / `T300Holding` | Input 811–900, Holding 2000–2025 |
|
|
74
|
+
| `NbeTemperatures` | Input 590 + n·3, sieben Geräte |
|
|
75
|
+
| `NbeSettingsBank` | Holding 213/233/253 + n |
|
|
76
|
+
| `RoomNames` | Holding 620 + n·10, acht Slots |
|
|
77
|
+
|
|
78
|
+
Namens-Slot 0 ist die HBDE und hat **kein** NBE-Gerät; Slot 1–7 gehören zu den
|
|
79
|
+
Geräten 0–6 (`physical_index()`).
|
|
80
|
+
|
|
81
|
+
## Freischaltung
|
|
82
|
+
|
|
83
|
+
Register 438 = 55555 schaltet die vollen Modbus-Rechte frei. Ohne das antworten
|
|
84
|
+
460 und 467–469 nicht und Schreibzugriffe werden abgelehnt. `ProxonDevice`
|
|
85
|
+
erledigt das einmal je Verbindung — es ist ein Flash-Write im Gerät, gehört
|
|
86
|
+
also nicht in jeden Zyklus — und setzt es nach einem Verbindungsabriss neu.
|
|
87
|
+
|
|
88
|
+
## Teilausfall
|
|
89
|
+
|
|
90
|
+
Einzelne Blöcke scheitern im Normalbetrieb, ohne dass die Anlage weg wäre.
|
|
91
|
+
`async_update()` gibt die Namen der gescheiterten Baugruppen zurück und lässt
|
|
92
|
+
deren zuletzt gelesene Werte stehen; `ProxonUnreachable` fliegt erst, wenn
|
|
93
|
+
**keine** Baugruppe gelesen werden konnte.
|
|
94
|
+
|
|
95
|
+
## Tests
|
|
96
|
+
|
|
97
|
+
Vollständig ohne Hardware, über den Mock aus `modbus_connection`:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install -e ".[tmodbus,test]"
|
|
101
|
+
pytest
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Die Testdaten sind echte Registerwerte, am 2026-09-02 von der Anlage gelesen.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# proxon-modbus
|
|
2
|
+
|
|
3
|
+
Gerätebibliothek für **Proxon FWT 2.0** (Lüftungs-Wärmepumpe) und **T300**
|
|
4
|
+
(Warmwasser-Wärmepumpe) über Modbus RTU.
|
|
5
|
+
|
|
6
|
+
Die Bibliothek besitzt den Transport **nicht**. Anwendungen reichen eine
|
|
7
|
+
`modbus_connection.ModbusUnit` herein — Backend `tmodbus` oder `pymodbus` —
|
|
8
|
+
und bekommen dafür das Registerwissen der Anlage. Damit lässt sich sie ohne
|
|
9
|
+
Hardware testen und teilt sich eine Verbindung mit anderen Verbrauchern
|
|
10
|
+
desselben RS485-Strangs.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from modbus_connection import ModbusTcpParams
|
|
14
|
+
from modbus_connection.tmodbus import ModbusConnection
|
|
15
|
+
from proxon_modbus import ProxonDevice
|
|
16
|
+
|
|
17
|
+
conn = ModbusConnection(
|
|
18
|
+
ModbusTcpParams(host="192.0.2.10", port=502, framer="rtu"),
|
|
19
|
+
message_spacing=0.15, # Pause zwischen Blöcken
|
|
20
|
+
connect_delay=0.30, # Wartezeit nach Verbindungsaufbau
|
|
21
|
+
)
|
|
22
|
+
device = ProxonDevice(conn.for_unit(41))
|
|
23
|
+
await device.async_update()
|
|
24
|
+
|
|
25
|
+
print(device.t300_input.t21_behaelter_mitte) # 57.5
|
|
26
|
+
print(device.fwt_holding.bypass_min_frischluft) # 8.0
|
|
27
|
+
print([d.temperatur for d in device.nbe_temperatures.devices])
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Warum eigene Feldtypen
|
|
31
|
+
|
|
32
|
+
Zwei Eigenheiten der Anlage kann `modbus_connection` nicht von sich aus:
|
|
33
|
+
|
|
34
|
+
**Skalierung und Offset sind anders gemeint.** Proxon rechnet
|
|
35
|
+
`(roh + offset) / scale`, die Bibliothek `roh * scale + offset`; Proxons
|
|
36
|
+
`scale` ist ein Divisor, ihrer ein Multiplikator. `proxon_number()` nimmt die
|
|
37
|
+
Proxon-Zahlen entgegen und rechnet um — Registerzeilen lassen sich also 1:1
|
|
38
|
+
aus der Anlagendokumentation übernehmen. Ohne diese Umrechnung liest der
|
|
39
|
+
Behälterfühler T21 statt **57,5 °C** ganze **−842,5 °C**.
|
|
40
|
+
|
|
41
|
+
**Fremde Frames müssen verworfen werden.** Die USR-RS485-Bridge spiegelt den
|
|
42
|
+
gesamten Busverkehr in die TCP-Verbindung; einzelne Register enthalten dadurch
|
|
43
|
+
Werte anderer Geräte (klassisch: 128 °C Warmwasser). 46 der 94 Register tragen
|
|
44
|
+
deshalb einen gültigen Rohbereich. `nan=` der Bibliothek trifft nur einzelne
|
|
45
|
+
Werte, nicht Bereiche — `RangedNumberField` prüft `min_raw`/`max_raw` auf dem
|
|
46
|
+
vorzeichenbehafteten Rohwert und liefert sonst `None`.
|
|
47
|
+
|
|
48
|
+
Dazu kommt `Latin1StringField`: die Raumnamen liegen als zwei Latin-1-Bytes je
|
|
49
|
+
Register, das `StringField` der Bibliothek ist ASCII — „Küche" käme kaputt an.
|
|
50
|
+
|
|
51
|
+
## Aufbau
|
|
52
|
+
|
|
53
|
+
| Baugruppe | Register |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `FwtInput` | Input 0–51, 154–265 |
|
|
56
|
+
| `FwtHolding` | Holding 16–22, 41–143, 187, 438, 460, 467–469, 613–619 |
|
|
57
|
+
| `T300Input` / `T300Holding` | Input 811–900, Holding 2000–2025 |
|
|
58
|
+
| `NbeTemperatures` | Input 590 + n·3, sieben Geräte |
|
|
59
|
+
| `NbeSettingsBank` | Holding 213/233/253 + n |
|
|
60
|
+
| `RoomNames` | Holding 620 + n·10, acht Slots |
|
|
61
|
+
|
|
62
|
+
Namens-Slot 0 ist die HBDE und hat **kein** NBE-Gerät; Slot 1–7 gehören zu den
|
|
63
|
+
Geräten 0–6 (`physical_index()`).
|
|
64
|
+
|
|
65
|
+
## Freischaltung
|
|
66
|
+
|
|
67
|
+
Register 438 = 55555 schaltet die vollen Modbus-Rechte frei. Ohne das antworten
|
|
68
|
+
460 und 467–469 nicht und Schreibzugriffe werden abgelehnt. `ProxonDevice`
|
|
69
|
+
erledigt das einmal je Verbindung — es ist ein Flash-Write im Gerät, gehört
|
|
70
|
+
also nicht in jeden Zyklus — und setzt es nach einem Verbindungsabriss neu.
|
|
71
|
+
|
|
72
|
+
## Teilausfall
|
|
73
|
+
|
|
74
|
+
Einzelne Blöcke scheitern im Normalbetrieb, ohne dass die Anlage weg wäre.
|
|
75
|
+
`async_update()` gibt die Namen der gescheiterten Baugruppen zurück und lässt
|
|
76
|
+
deren zuletzt gelesene Werte stehen; `ProxonUnreachable` fliegt erst, wenn
|
|
77
|
+
**keine** Baugruppe gelesen werden konnte.
|
|
78
|
+
|
|
79
|
+
## Tests
|
|
80
|
+
|
|
81
|
+
Vollständig ohne Hardware, über den Mock aus `modbus_connection`:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install -e ".[tmodbus,test]"
|
|
85
|
+
pytest
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Die Testdaten sind echte Registerwerte, am 2026-09-02 von der Anlage gelesen.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "proxon-modbus"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Gerätebibliothek für Proxon FWT 2.0 und T300 über Modbus RTU"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["modbus-connection>=4.10.0"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
tmodbus = ["modbus-connection[tmodbus]>=4.10.0"]
|
|
16
|
+
pymodbus = ["modbus-connection[pymodbus]>=4.10.0"]
|
|
17
|
+
test = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/proxon_modbus"]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
asyncio_mode = "auto"
|
|
24
|
+
testpaths = ["tests"]
|
|
25
|
+
|
|
26
|
+
[tool.ruff]
|
|
27
|
+
line-length = 120
|
|
28
|
+
target-version = "py312"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""proxon-modbus — Geräte-Bibliothek für Proxon FWT 2.0 / T300 über Modbus RTU.
|
|
2
|
+
|
|
3
|
+
Besitzt den Transport **nicht**: Anwendungen reichen eine
|
|
4
|
+
`modbus_connection.ModbusUnit` herein (Backend tmodbus oder pymodbus) und
|
|
5
|
+
bekommen dafür das Registerwissen der Anlage.
|
|
6
|
+
|
|
7
|
+
from modbus_connection import ModbusTcpParams
|
|
8
|
+
from modbus_connection.tmodbus import ModbusConnection
|
|
9
|
+
from proxon_modbus import ProxonDevice
|
|
10
|
+
|
|
11
|
+
conn = ModbusConnection(
|
|
12
|
+
ModbusTcpParams(host="192.0.2.10", port=502, framer="rtu"),
|
|
13
|
+
message_spacing=0.15, # entspricht _INTER_BLOCK_DELAY
|
|
14
|
+
connect_delay=0.30, # entspricht _POST_CONNECT_DRAIN
|
|
15
|
+
)
|
|
16
|
+
device = ProxonDevice(conn.for_unit(41))
|
|
17
|
+
await device.async_update()
|
|
18
|
+
print(device.t300_input.t21_behaelter_mitte)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .device import (
|
|
22
|
+
UNLOCK_REGISTER,
|
|
23
|
+
UNLOCK_VALUE,
|
|
24
|
+
ProxonDevice,
|
|
25
|
+
ProxonUnreachable,
|
|
26
|
+
)
|
|
27
|
+
from .fields import (
|
|
28
|
+
Latin1StringField,
|
|
29
|
+
RangedNumberField,
|
|
30
|
+
latin1_string,
|
|
31
|
+
proxon_number,
|
|
32
|
+
)
|
|
33
|
+
from .fwt import Betriebsart, BypassModus, FwtHolding, FwtInput
|
|
34
|
+
from .nbe import (
|
|
35
|
+
NBE_DEVICE_COUNT,
|
|
36
|
+
NAME_SLOT_COUNT,
|
|
37
|
+
NbeSettings,
|
|
38
|
+
NbeSettingsBank,
|
|
39
|
+
NbeTemperature,
|
|
40
|
+
NbeTemperatures,
|
|
41
|
+
RoomNames,
|
|
42
|
+
RoomNameSlot,
|
|
43
|
+
is_configured_room,
|
|
44
|
+
physical_index,
|
|
45
|
+
)
|
|
46
|
+
from .t300 import T300, T300Holding, T300Input
|
|
47
|
+
|
|
48
|
+
__version__ = "0.1.1"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"ProxonDevice",
|
|
52
|
+
"ProxonUnreachable",
|
|
53
|
+
"UNLOCK_REGISTER",
|
|
54
|
+
"UNLOCK_VALUE",
|
|
55
|
+
"FwtInput",
|
|
56
|
+
"FwtHolding",
|
|
57
|
+
"Betriebsart",
|
|
58
|
+
"BypassModus",
|
|
59
|
+
"T300",
|
|
60
|
+
"T300Input",
|
|
61
|
+
"T300Holding",
|
|
62
|
+
"NbeTemperature",
|
|
63
|
+
"NbeTemperatures",
|
|
64
|
+
"NbeSettings",
|
|
65
|
+
"NbeSettingsBank",
|
|
66
|
+
"RoomNames",
|
|
67
|
+
"RoomNameSlot",
|
|
68
|
+
"NBE_DEVICE_COUNT",
|
|
69
|
+
"NAME_SLOT_COUNT",
|
|
70
|
+
"is_configured_room",
|
|
71
|
+
"physical_index",
|
|
72
|
+
"RangedNumberField",
|
|
73
|
+
"Latin1StringField",
|
|
74
|
+
"proxon_number",
|
|
75
|
+
"latin1_string",
|
|
76
|
+
"__version__",
|
|
77
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Proxon-Anlage als Ganzes: FWT + optionale T300 + NBE.
|
|
2
|
+
|
|
3
|
+
Bündelt die Baugruppen und trägt das gerätespezifische Wissen, das keine
|
|
4
|
+
Modbus-Bibliothek kennen kann:
|
|
5
|
+
|
|
6
|
+
* **Freischaltung.** Register 438 = 55555 schaltet die vollen Modbus-Rechte
|
|
7
|
+
frei. Ohne das antworten 460 und 467–469 gar nicht, und Schreibzugriffe
|
|
8
|
+
werden abgelehnt. Einmal je Verbindung, nicht je Zyklus — es ist ein
|
|
9
|
+
Flash-Write im Gerät.
|
|
10
|
+
|
|
11
|
+
* **Teilausfall ist normal.** Die USR-Bridge spiegelt fremde RS485-Frames in
|
|
12
|
+
unsere Verbindung; einzelne Blöcke scheitern regelmäßig, ohne dass die Anlage
|
|
13
|
+
weg wäre. Ein gescheiterter Block darf deshalb nicht die ganze Aktualisierung
|
|
14
|
+
umwerfen — die Komponente behält ihre zuletzt gelesenen Werte
|
|
15
|
+
(entspricht `_prev_raw` in der bestehenden Integration). Erst wenn **alle**
|
|
16
|
+
Blöcke scheitern, ist es ein echter Ausfall.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
from modbus_connection import ModbusError, ModbusUnit
|
|
24
|
+
|
|
25
|
+
from .fwt import FwtHolding, FwtInput
|
|
26
|
+
from .nbe import NbeSettingsBank, NbeTemperatures, RoomNames
|
|
27
|
+
from .t300 import T300Holding, T300Input
|
|
28
|
+
|
|
29
|
+
_LOGGER = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
UNLOCK_REGISTER = 438
|
|
32
|
+
UNLOCK_VALUE = 55555
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProxonUnreachable(RuntimeError):
|
|
36
|
+
"""Kein einziger Block konnte gelesen werden."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProxonDevice:
|
|
40
|
+
"""Alle Register-Baugruppen einer Proxon-Installation."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, unit: ModbusUnit, *, has_t300: bool = True) -> None:
|
|
43
|
+
self._unit = unit
|
|
44
|
+
self.has_t300 = has_t300
|
|
45
|
+
|
|
46
|
+
self.fwt_input = FwtInput(unit)
|
|
47
|
+
self.fwt_holding = FwtHolding(unit)
|
|
48
|
+
self.nbe_temperatures = NbeTemperatures(unit)
|
|
49
|
+
self.nbe_settings = NbeSettingsBank(unit)
|
|
50
|
+
self.room_names = RoomNames(unit)
|
|
51
|
+
self.t300_input = T300Input(unit) if has_t300 else None
|
|
52
|
+
self.t300_holding = T300Holding(unit) if has_t300 else None
|
|
53
|
+
|
|
54
|
+
self._unlocked = False
|
|
55
|
+
# Nach einem Verbindungsabriss ist die Freischaltung der alten Sitzung
|
|
56
|
+
# nichts mehr wert — beim nächsten Zyklus neu setzen.
|
|
57
|
+
unit.on_connection_lost(self._on_connection_lost)
|
|
58
|
+
|
|
59
|
+
def _on_connection_lost(self) -> None:
|
|
60
|
+
_LOGGER.debug("Verbindung verloren — Freischaltung wird neu gesetzt")
|
|
61
|
+
self._unlocked = False
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def components(self):
|
|
65
|
+
"""Alle aktiven Baugruppen (ohne T300, wenn keine verbaut ist)."""
|
|
66
|
+
parts = [
|
|
67
|
+
self.fwt_input,
|
|
68
|
+
self.fwt_holding,
|
|
69
|
+
self.nbe_temperatures,
|
|
70
|
+
self.nbe_settings,
|
|
71
|
+
]
|
|
72
|
+
if self.t300_input is not None and self.t300_holding is not None:
|
|
73
|
+
parts += [self.t300_input, self.t300_holding]
|
|
74
|
+
return parts
|
|
75
|
+
|
|
76
|
+
async def async_unlock(self) -> bool:
|
|
77
|
+
"""Volle Modbus-Rechte anfordern. Einmal je Verbindung."""
|
|
78
|
+
if self._unlocked:
|
|
79
|
+
return True
|
|
80
|
+
try:
|
|
81
|
+
await self._unit.write_register(UNLOCK_REGISTER, UNLOCK_VALUE)
|
|
82
|
+
except ModbusError as exc:
|
|
83
|
+
_LOGGER.debug("Freischaltung fehlgeschlagen: %s", exc)
|
|
84
|
+
return False
|
|
85
|
+
self._unlocked = True
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
async def async_update(self) -> list[str]:
|
|
89
|
+
"""Alle Baugruppen lesen.
|
|
90
|
+
|
|
91
|
+
Gibt die Namen der gescheiterten Baugruppen zurück (leer = alles gut).
|
|
92
|
+
Wirft `ProxonUnreachable`, wenn **keine** Baugruppe gelesen werden
|
|
93
|
+
konnte — nur das ist ein Geräteausfall.
|
|
94
|
+
"""
|
|
95
|
+
await self.async_unlock()
|
|
96
|
+
|
|
97
|
+
failed: list[str] = []
|
|
98
|
+
for component in self.components:
|
|
99
|
+
try:
|
|
100
|
+
await component.async_update()
|
|
101
|
+
except ModbusError as exc:
|
|
102
|
+
name = type(component).__name__
|
|
103
|
+
failed.append(name)
|
|
104
|
+
_LOGGER.debug("Baugruppe %s nicht gelesen: %s", name, exc)
|
|
105
|
+
|
|
106
|
+
if len(failed) == len(self.components):
|
|
107
|
+
raise ProxonUnreachable(", ".join(failed))
|
|
108
|
+
return failed
|
|
109
|
+
|
|
110
|
+
async def async_update_room_names(self) -> None:
|
|
111
|
+
"""Raumnamen lesen — ändern sich fast nie, gehört nicht in jeden Zyklus."""
|
|
112
|
+
await self.async_unlock()
|
|
113
|
+
await self.room_names.async_update()
|
|
114
|
+
|
|
115
|
+
async def async_write(self, component, field: str, value) -> None:
|
|
116
|
+
"""Feld schreiben; sorgt vorher für die Freischaltung."""
|
|
117
|
+
await self.async_unlock()
|
|
118
|
+
await component.write(field, value)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Feld-Fabriken, die Proxons Register-Semantik auf `modbus_connection` abbilden.
|
|
2
|
+
|
|
3
|
+
Zwei Unterschiede zur Bibliothek, beide gemessen (2026-09-02), nicht geraten:
|
|
4
|
+
|
|
5
|
+
1. **Skalierung ist invertiert.** Proxon speichert `scale` als *Divisor*
|
|
6
|
+
(`real = roh / scale`), `modbus_connection.gauge()` nimmt einen *Multiplikator*
|
|
7
|
+
(`real = roh * scale`). Proxon-`scale=10` ist also `scale=0.1`.
|
|
8
|
+
|
|
9
|
+
2. **Offset greift an anderer Stelle.** Proxon rechnet `(roh + offset) / scale`,
|
|
10
|
+
die Bibliothek `roh * scale + offset`. Umrechnung:
|
|
11
|
+
|
|
12
|
+
mc_scale = 1 / proxon_scale
|
|
13
|
+
mc_offset = proxon_offset / proxon_scale
|
|
14
|
+
|
|
15
|
+
Beispiel T21 Behälter Mitte (Reg 814, scale=10, offset=-1000): roh 1575
|
|
16
|
+
→ Proxon `(1575-1000)/10 = 57,5 °C`; naiv portiert (`1575*0.1 - 1000`)
|
|
17
|
+
käme **-842,5 °C** heraus.
|
|
18
|
+
|
|
19
|
+
Dazu der Bereichsfilter (`min_raw`/`max_raw`): die USR-Bridge spiegelt fremde
|
|
20
|
+
RS485-Frames in unsere Verbindung, was einzelne Register mit Müll füllt
|
|
21
|
+
(klassisch: 128 °C Warmwasser). Die Bibliothek kennt nur `nan=` für einzelne
|
|
22
|
+
Sentinel-Werte, keine Bereiche — und ihr `convert=`-Haken ist keine Lösung, weil
|
|
23
|
+
er die Skalierung kurzschließt und pro unbekanntem Wert eine Warnung loggt.
|
|
24
|
+
Deshalb ein eigenes Feld, das `decode()` überschreibt.
|
|
25
|
+
|
|
26
|
+
46 der 94 Proxon-Register tragen so einen Bereichsschutz.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from modbus_connection.model import NumberField, StringField
|
|
34
|
+
from modbus_connection.model.fields import WriteValidator
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RangedNumberField(NumberField[float]):
|
|
38
|
+
"""Zahlenfeld, das Rohwerte außerhalb `[min_raw, max_raw]` als None liefert.
|
|
39
|
+
|
|
40
|
+
Die Prüfung läuft auf dem **vorzeichenbehafteten** Rohwert, genau wie
|
|
41
|
+
`_decode()` in der bestehenden Integration: dort wird erst `_to_signed16()`
|
|
42
|
+
angewandt und danach gegen min/max verglichen.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
address: int,
|
|
48
|
+
*,
|
|
49
|
+
min_raw: int | None = None,
|
|
50
|
+
max_raw: int | None = None,
|
|
51
|
+
**kwargs: Any,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(address, **kwargs)
|
|
54
|
+
self.min_raw = min_raw
|
|
55
|
+
self.max_raw = max_raw
|
|
56
|
+
|
|
57
|
+
def decode(self, words: list[int], scale_exponent: int | None = None) -> Any:
|
|
58
|
+
if self.min_raw is not None or self.max_raw is not None:
|
|
59
|
+
# Alle Proxon-Register sind einzelne 16-Bit-Wörter.
|
|
60
|
+
raw = words[0]
|
|
61
|
+
value = raw - 0x10000 if (self.signed and raw >= 0x8000) else raw
|
|
62
|
+
if self.min_raw is not None and value < self.min_raw:
|
|
63
|
+
return None
|
|
64
|
+
if self.max_raw is not None and value > self.max_raw:
|
|
65
|
+
return None
|
|
66
|
+
return super().decode(words, scale_exponent)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Latin1StringField(StringField):
|
|
70
|
+
"""Name-Register der Proxon: zwei **Latin-1**-Bytes je Register, big-endian.
|
|
71
|
+
|
|
72
|
+
`StringField` der Bibliothek ist ASCII und null-gepolstert — für „Küche"
|
|
73
|
+
oder „Büro" reicht das nicht. Kodierung und Abschneiden am ersten Nullbyte
|
|
74
|
+
entsprechen exakt `config_flow._discover_rooms()` und
|
|
75
|
+
`text._name_to_registers()` der bestehenden Integration.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def decode(self, words: list[int], scale_exponent: int | None = None) -> str:
|
|
79
|
+
chars: list[str] = []
|
|
80
|
+
for word in words:
|
|
81
|
+
# chr() auf einem Byte 0–255 *ist* die Latin-1-Zuordnung.
|
|
82
|
+
chars.append(chr((word >> 8) & 0xFF))
|
|
83
|
+
chars.append(chr(word & 0xFF))
|
|
84
|
+
return "".join(chars).split("\x00")[0].strip()
|
|
85
|
+
|
|
86
|
+
def encode(self, value: Any, scale_exponent: int | None = None) -> list[int]:
|
|
87
|
+
width = self.count * 2
|
|
88
|
+
encoded = str(value).encode("latin-1", errors="replace")[:width]
|
|
89
|
+
padded = encoded.ljust(width, b"\x00")
|
|
90
|
+
return [(padded[i] << 8) | padded[i + 1] for i in range(0, width, 2)]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def latin1_string(
|
|
94
|
+
address: int,
|
|
95
|
+
length: int,
|
|
96
|
+
*,
|
|
97
|
+
stride: int = 0,
|
|
98
|
+
writable: bool | WriteValidator = False,
|
|
99
|
+
) -> Latin1StringField:
|
|
100
|
+
"""Latin-1-Namensfeld über `length` Register (2 Zeichen je Register)."""
|
|
101
|
+
return Latin1StringField(address, count=length, stride=stride, writable=writable)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def proxon_number(
|
|
105
|
+
address: int,
|
|
106
|
+
*,
|
|
107
|
+
scale: float = 1,
|
|
108
|
+
offset: int = 0,
|
|
109
|
+
signed: bool = False,
|
|
110
|
+
min_raw: int | None = None,
|
|
111
|
+
max_raw: int | None = None,
|
|
112
|
+
writable: bool | WriteValidator = False,
|
|
113
|
+
unit: str | None = None,
|
|
114
|
+
) -> RangedNumberField:
|
|
115
|
+
"""Feld aus einer Proxon-`ModbusRegister`-Zeile.
|
|
116
|
+
|
|
117
|
+
`scale` und `offset` werden in Proxon-Semantik übergeben (Divisor bzw.
|
|
118
|
+
Summand vor der Division) und hier umgerechnet — Aufrufer schreiben also
|
|
119
|
+
dieselben Zahlen wie in `const.py`.
|
|
120
|
+
"""
|
|
121
|
+
return RangedNumberField(
|
|
122
|
+
address,
|
|
123
|
+
scale=1 / scale,
|
|
124
|
+
offset=offset / scale,
|
|
125
|
+
signed=signed,
|
|
126
|
+
min_raw=min_raw,
|
|
127
|
+
max_raw=max_raw,
|
|
128
|
+
writable=writable,
|
|
129
|
+
unit=unit,
|
|
130
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""FWT 2.0 — Lüftungs-Wärmepumpe, Register-Modell.
|
|
2
|
+
|
|
3
|
+
Adressen, Skalen und Bereiche 1:1 aus `custom_components/proxon/const.py`
|
|
4
|
+
(FWT_INPUT_REGISTERS / FWT_HOLDING_REGISTERS). Proxon-Semantik bleibt in den
|
|
5
|
+
Aufrufen stehen (`scale` = Divisor), die Umrechnung macht `proxon_number()`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import IntEnum
|
|
11
|
+
|
|
12
|
+
from modbus_connection.model import Component
|
|
13
|
+
|
|
14
|
+
from .fields import proxon_number
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Betriebsart(IntEnum):
|
|
18
|
+
"""Holding 16 / Input 23. 9 = Test ist Technikermodus und bleibt draußen."""
|
|
19
|
+
|
|
20
|
+
AUS = 0
|
|
21
|
+
SOMMER = 1
|
|
22
|
+
WINTER = 2
|
|
23
|
+
KOMFORT = 3
|
|
24
|
+
OFEN = 4
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BypassModus(IntEnum):
|
|
28
|
+
"""Holding 21 (Menü T05). 1/2 hebeln die Automatik dauerhaft aus."""
|
|
29
|
+
|
|
30
|
+
GEREGELT = 0
|
|
31
|
+
GESCHLOSSEN = 1
|
|
32
|
+
GEOEFFNET = 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FwtInput(Component):
|
|
36
|
+
"""Nur-Lese-Register der FWT (FC4).
|
|
37
|
+
|
|
38
|
+
Die Anlage antwortet auf die Blöcke 0–51 und 154–265; dazwischen liegt eine
|
|
39
|
+
Lücke von rund 100 Registern, die sie nicht bedient. `max_gap` ist deshalb
|
|
40
|
+
klein genug gewählt, dass der Planer die beiden Blöcke **nicht** zu einem
|
|
41
|
+
zusammenzieht — ein Block über die Lücke quittiert der Adapter mit Fehlern.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
register_space = "input"
|
|
45
|
+
max_span = 112
|
|
46
|
+
max_gap = 60
|
|
47
|
+
|
|
48
|
+
# Ventilatoren
|
|
49
|
+
drehzahl_zuluft = proxon_number(0, signed=True, unit="rpm")
|
|
50
|
+
drehzahl_abluft = proxon_number(1, signed=True, unit="rpm")
|
|
51
|
+
stufe_zuluft = proxon_number(154, signed=True)
|
|
52
|
+
stufe_abluft = proxon_number(211, signed=True)
|
|
53
|
+
|
|
54
|
+
# Leistung
|
|
55
|
+
power_pcb = proxon_number(19, scale=10, signed=True, unit="W")
|
|
56
|
+
power_fu = proxon_number(20, scale=10, signed=True, unit="W")
|
|
57
|
+
power_total = proxon_number(25, scale=10, signed=True, unit="W")
|
|
58
|
+
|
|
59
|
+
# Luft
|
|
60
|
+
co2_sensor1 = proxon_number(21, signed=True, unit="ppm")
|
|
61
|
+
rf_sensor1 = proxon_number(22, signed=True, unit="%")
|
|
62
|
+
druckventilator_pa = proxon_number(24, scale=10, signed=True, unit="Pa")
|
|
63
|
+
luftmenge_m3h = proxon_number(26, scale=10, signed=True, unit="m³/h")
|
|
64
|
+
umluft_aktiv = proxon_number(39, signed=True)
|
|
65
|
+
|
|
66
|
+
betriebsart = proxon_number(23, signed=True)
|
|
67
|
+
|
|
68
|
+
# Arbeitszahlen
|
|
69
|
+
jaz_komp_1h = proxon_number(28, scale=100, signed=True)
|
|
70
|
+
jaz_komp_24h = proxon_number(29, scale=100, signed=True)
|
|
71
|
+
jaz_total_1h = proxon_number(33, scale=100, signed=True)
|
|
72
|
+
jaz_total_24h = proxon_number(34, scale=100, signed=True)
|
|
73
|
+
|
|
74
|
+
# Temperaturen: roh = °C * 100
|
|
75
|
+
temp_hnbe = proxon_number(40, scale=100, min_raw=0, max_raw=4000, unit="°C")
|
|
76
|
+
temp_hbde = proxon_number(41, scale=100, min_raw=0, max_raw=4000, unit="°C")
|
|
77
|
+
t5_vorverdampfer = proxon_number(175, scale=100, min_raw=0, max_raw=6000, unit="°C")
|
|
78
|
+
t6_verdampfer = proxon_number(176, scale=100, min_raw=0, max_raw=6000, unit="°C")
|
|
79
|
+
t13_kompressor = proxon_number(180, scale=100, min_raw=0, max_raw=15000, unit="°C")
|
|
80
|
+
t9_aussenluft = proxon_number(181, scale=100, min_raw=0, max_raw=5000, unit="°C")
|
|
81
|
+
t1_zuluft = proxon_number(195, scale=100, min_raw=0, max_raw=6000, unit="°C")
|
|
82
|
+
t7_abluft = proxon_number(196, scale=100, min_raw=0, max_raw=6000, unit="°C")
|
|
83
|
+
t4_fortluft = proxon_number(197, scale=100, min_raw=0, max_raw=6000, unit="°C")
|
|
84
|
+
t3_frischluft = proxon_number(198, scale=100, min_raw=0, max_raw=6000, unit="°C")
|
|
85
|
+
t21_zone1 = proxon_number(263, scale=100, min_raw=0, max_raw=6000, unit="°C")
|
|
86
|
+
t22_zone2 = proxon_number(264, scale=100, min_raw=0, max_raw=6000, unit="°C")
|
|
87
|
+
soll_zone2 = proxon_number(251, scale=100, min_raw=1000, max_raw=3000, unit="°C")
|
|
88
|
+
soll_zone1 = proxon_number(265, scale=100, min_raw=1000, max_raw=3000, unit="°C")
|
|
89
|
+
|
|
90
|
+
# Kompressor
|
|
91
|
+
kompressor_status = proxon_number(162, signed=True)
|
|
92
|
+
kompressor_leistung = proxon_number(171, scale=100, signed=True, unit="%")
|
|
93
|
+
kompressor_drehzahl = proxon_number(190, signed=True, unit="rpm")
|
|
94
|
+
|
|
95
|
+
# Ventile / Zustände
|
|
96
|
+
schieber_position = proxon_number(159, signed=True)
|
|
97
|
+
erdwaerme_aktiv = proxon_number(220, signed=True)
|
|
98
|
+
magnetventil = proxon_number(221, signed=True)
|
|
99
|
+
bypass_zustand = proxon_number(222, signed=True)
|
|
100
|
+
vierwege_ventil = proxon_number(223, signed=True)
|
|
101
|
+
|
|
102
|
+
# Fehler
|
|
103
|
+
stoerung = proxon_number(47, signed=True)
|
|
104
|
+
error_status1 = proxon_number(48, signed=True)
|
|
105
|
+
error_status2 = proxon_number(49, signed=True)
|
|
106
|
+
error_status3 = proxon_number(50, signed=True)
|
|
107
|
+
error_status4 = proxon_number(51, signed=True)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def filter_wechsel_faellig(self) -> bool | None:
|
|
111
|
+
"""Bit 1 aus Fehlerstatus 1 — abgeleitet, kein eigenes Register."""
|
|
112
|
+
if self.error_status1 is None:
|
|
113
|
+
return None
|
|
114
|
+
return bool(int(self.error_status1) & 0x0002)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class FwtHolding(Component):
|
|
118
|
+
"""Lese-/Schreibregister der FWT (FC3/FC6).
|
|
119
|
+
|
|
120
|
+
460 und 467–469 antworten erst nach dem Freischalten von Register 438
|
|
121
|
+
(= 55555); ohne Unlock liefern sie Fehler, nicht etwa Nullen.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
register_space = "holding"
|
|
125
|
+
max_span = 103
|
|
126
|
+
max_gap = 60
|
|
127
|
+
|
|
128
|
+
sollbetriebsart = proxon_number(16, min_raw=0, max_raw=9, writable=True)
|
|
129
|
+
luefterstufe = proxon_number(22, min_raw=1, max_raw=4, writable=True)
|
|
130
|
+
kuehlung_freigabe = proxon_number(62, min_raw=0, max_raw=1, writable=True)
|
|
131
|
+
betriebsart_wp = proxon_number(69)
|
|
132
|
+
schreibrechte = proxon_number(438)
|
|
133
|
+
|
|
134
|
+
soll_temp_zone1 = proxon_number(70, scale=100, signed=True, min_raw=1000, max_raw=3000, writable=True, unit="°C")
|
|
135
|
+
soll_temp_zone2 = proxon_number(75, scale=100, signed=True, min_raw=1000, max_raw=3000, writable=True, unit="°C")
|
|
136
|
+
intensivlueftung = proxon_number(133, min_raw=0, max_raw=1440, writable=True, unit="min")
|
|
137
|
+
|
|
138
|
+
# Schwellen (nur lesen)
|
|
139
|
+
wp_kuehlschwelle = proxon_number(41, scale=100, signed=True, unit="°C")
|
|
140
|
+
wp_einschaltschwelle = proxon_number(42, scale=100, signed=True, unit="°C")
|
|
141
|
+
wp_ausschaltschwelle = proxon_number(134, scale=100, signed=True, unit="°C")
|
|
142
|
+
frischluft_obergrenze = proxon_number(97, scale=100, signed=True, unit="°C")
|
|
143
|
+
frischluft_untergrenze = proxon_number(98, scale=100, signed=True, unit="°C")
|
|
144
|
+
|
|
145
|
+
# Sommerbypass
|
|
146
|
+
bypass_min_frischluft = proxon_number(19, scale=100, signed=True, min_raw=800, max_raw=2000, writable=True, unit="°C")
|
|
147
|
+
bypass_modus = proxon_number(21, min_raw=0, max_raw=2, writable=True)
|
|
148
|
+
bypass_hysterese = proxon_number(102, scale=100, signed=True, min_raw=0, max_raw=1000, writable=True, unit="K")
|
|
149
|
+
bypass_laufzeit = proxon_number(107, min_raw=10, max_raw=20, writable=True, unit="s")
|
|
150
|
+
|
|
151
|
+
# Bewusst ohne Bereichsschutz: ein Fremdframe soll hier keinen None-Wert
|
|
152
|
+
# erzeugen, sonst flackert die PTC-Freigabe im Frontend.
|
|
153
|
+
hbde_ptc_freigabe = proxon_number(187, writable=True)
|
|
154
|
+
|
|
155
|
+
# Nachtabsenkung
|
|
156
|
+
zeitprogramm_luft = proxon_number(613, min_raw=0, max_raw=1, writable=True)
|
|
157
|
+
nacht_temperatur = proxon_number(618, scale=100, signed=True, min_raw=1000, max_raw=3000, writable=True, unit="°C")
|
|
158
|
+
nachtabsenkung = proxon_number(619, min_raw=0, max_raw=1, writable=True)
|
|
159
|
+
|
|
160
|
+
# Filter / Stundenzähler — brauchen Unlock (Reg 438 = 55555)
|
|
161
|
+
geraetefilter_standzeit_monate = proxon_number(460, min_raw=3, max_raw=8, unit="Monate")
|
|
162
|
+
fwt_betriebsstunden = proxon_number(467, min_raw=0, max_raw=65535, unit="h")
|
|
163
|
+
umluft_betriebsstunden = proxon_number(468, min_raw=0, max_raw=65535, unit="h")
|
|
164
|
+
geraetefilter_stunden = proxon_number(469, min_raw=0, max_raw=65535, unit="h")
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def geraetefilter_remaining_days(self) -> float | None:
|
|
168
|
+
"""Resttage bis Filterwechsel, aus Standzeit (460) und Laufzeit (469)."""
|
|
169
|
+
monate = self.geraetefilter_standzeit_monate
|
|
170
|
+
stunden = self.geraetefilter_stunden
|
|
171
|
+
if monate is None or stunden is None:
|
|
172
|
+
return None
|
|
173
|
+
remaining = ((monate * 30 * 24) - stunden) / 24
|
|
174
|
+
return round(max(0.0, remaining), 1)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""NBE-Nachheizregister und Raumnamen.
|
|
2
|
+
|
|
3
|
+
Bis zu sieben physische NBE-Geräte, deren Register in drei parallelen Reihen
|
|
4
|
+
liegen — je Gerät ein Schritt weiter:
|
|
5
|
+
|
|
6
|
+
Temperatur Input 590 + n*3 (roh/10, gültig 100–400 = 10–40 °C)
|
|
7
|
+
Offset Holding 213 + n (vorzeichenbehaftet, -10…10 K)
|
|
8
|
+
Mitteltemp. Holding 233 + n (vorzeichenbehaftet, 10…35 °C)
|
|
9
|
+
PTC-Freigabe Holding 253 + n
|
|
10
|
+
|
|
11
|
+
Die bestehende Integration bildet das mit `for n in range(7)` und
|
|
12
|
+
Schlüsseln wie `nbe_temp_3` ab. Hier übernimmt `repeating_group(..., stride=…)`
|
|
13
|
+
diese Arbeit: eine Deklaration, danach `rooms[n].temperatur`.
|
|
14
|
+
|
|
15
|
+
Namen liegen getrennt davon in acht Slots à zehn Registern ab 620.
|
|
16
|
+
Slot 0 ist die HBDE (Wohnen/Essen, **ohne** NBE-Sensor), Slot 1–7 gehören zu
|
|
17
|
+
den physischen Geräten 0–6 — der Versatz um eins ist die häufigste
|
|
18
|
+
Verwechslungsquelle in diesem Teil.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
from modbus_connection.model import Component, repeating_group
|
|
26
|
+
|
|
27
|
+
from .fields import latin1_string, proxon_number
|
|
28
|
+
|
|
29
|
+
NBE_DEVICE_COUNT = 7
|
|
30
|
+
NAME_SLOT_COUNT = 8
|
|
31
|
+
NAME_REGS = 10
|
|
32
|
+
|
|
33
|
+
# "Raum 1", "raum 3" … = Werkseinstellung, kein echter Name.
|
|
34
|
+
_DEFAULT_NAME = re.compile(r"^Raum\s*\d+$", re.IGNORECASE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NbeTemperature(Component):
|
|
38
|
+
"""Ein NBE-Temperaturfühler (Input, Schrittweite 3)."""
|
|
39
|
+
|
|
40
|
+
register_space = "input"
|
|
41
|
+
temperatur = proxon_number(590, scale=10, min_raw=100, max_raw=400, unit="°C")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NbeSettings(Component):
|
|
45
|
+
"""Einstellungen eines NBE-Geräts (Holding, Schrittweite 1)."""
|
|
46
|
+
|
|
47
|
+
register_space = "holding"
|
|
48
|
+
offset_k = proxon_number(213, signed=True, min_raw=-10, max_raw=10, writable=True, unit="K")
|
|
49
|
+
mitteltemperatur = proxon_number(233, signed=True, min_raw=10, max_raw=35, unit="°C")
|
|
50
|
+
ptc_freigabe = proxon_number(253, writable=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RoomNameSlot(Component):
|
|
54
|
+
"""Ein Namens-Slot: zehn Register, 20 Latin-1-Zeichen."""
|
|
55
|
+
|
|
56
|
+
register_space = "holding"
|
|
57
|
+
name = latin1_string(620, NAME_REGS, writable=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NbeTemperatures(Component):
|
|
61
|
+
register_space = "input"
|
|
62
|
+
devices = repeating_group(NBE_DEVICE_COUNT, NbeTemperature, stride=3)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NbeSettingsBank(Component):
|
|
66
|
+
register_space = "holding"
|
|
67
|
+
devices = repeating_group(NBE_DEVICE_COUNT, NbeSettings, stride=1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RoomNames(Component):
|
|
71
|
+
register_space = "holding"
|
|
72
|
+
slots = repeating_group(NAME_SLOT_COUNT, RoomNameSlot, stride=NAME_REGS)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_configured_room(name: str | None) -> bool:
|
|
76
|
+
"""True, wenn der Slot einen echten Namen trägt (nicht leer, nicht „Raum N")."""
|
|
77
|
+
if not name:
|
|
78
|
+
return False
|
|
79
|
+
return _DEFAULT_NAME.match(name) is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def physical_index(name_idx: int) -> int | None:
|
|
83
|
+
"""Namens-Slot → physisches NBE-Gerät. Slot 0 (HBDE) hat keines."""
|
|
84
|
+
return None if name_idx == 0 else name_idx - 1
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""T300 Warmwasser-Wärmepumpe — Register-Modell.
|
|
2
|
+
|
|
3
|
+
Erste portierte Baugruppe: klein (26 Register), in sich geschlossen und gegen
|
|
4
|
+
echte Werte prüfbar. Adressen und Bereiche 1:1 aus
|
|
5
|
+
`custom_components/proxon/const.py` (T300_INPUT_REGISTERS / T300_HOLDING_REGISTERS).
|
|
6
|
+
|
|
7
|
+
Input und Holding sind getrennte Register-Räume, brauchen also je eine
|
|
8
|
+
`Component`; `T300` fasst beide zusammen.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from modbus_connection import ModbusUnit
|
|
14
|
+
from modbus_connection.model import Component
|
|
15
|
+
|
|
16
|
+
from .fields import proxon_number
|
|
17
|
+
|
|
18
|
+
# Die Anlage beantwortet den Block 811–900 am Stück; so liest ihn die
|
|
19
|
+
# bestehende Integration seit je, und so ist er am 2026-09-02 gegen die echte
|
|
20
|
+
# Bridge verifiziert.
|
|
21
|
+
#
|
|
22
|
+
# Ohne `max_gap` plant die Bibliothek stattdessen vier kleine Reads
|
|
23
|
+
# (811+28, 861+2, 879+4, 899+2): weniger Register, aber **vier** Requests statt
|
|
24
|
+
# einem. Auf dieser USR-Bridge ist jeder zusätzliche Request eine weitere
|
|
25
|
+
# Gelegenheit, ein fremdes RS485-Frame als Antwort zu missdeuten — deshalb
|
|
26
|
+
# bewusst ein Block. Die größte Lücke ist 838→861 (22 Register).
|
|
27
|
+
#
|
|
28
|
+
# ⚠️ Noch nicht gemessen, welche Variante die Bridge besser verträgt. Bei der
|
|
29
|
+
# Migration nur *eine* Sache auf einmal ändern: erst die bewährte Blockform
|
|
30
|
+
# übernehmen, Alternativen später gegen echte Hardware vergleichen.
|
|
31
|
+
_T300_INPUT_SPAN = 90
|
|
32
|
+
_T300_INPUT_MAX_GAP = 30
|
|
33
|
+
_T300_HOLDING_SPAN = 26
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class T300Input(Component):
|
|
37
|
+
"""Nur-Lese-Register der T300 (FC4)."""
|
|
38
|
+
|
|
39
|
+
register_space = "input"
|
|
40
|
+
max_span = _T300_INPUT_SPAN
|
|
41
|
+
max_gap = _T300_INPUT_MAX_GAP
|
|
42
|
+
|
|
43
|
+
# Temperaturen: roh = (°C * 10) + 1000, Bereich 500–2000 = -50…+100 °C
|
|
44
|
+
t5_vorverdampfer = proxon_number(811, scale=10, offset=-1000, min_raw=500, max_raw=2000, unit="°C")
|
|
45
|
+
t6_verdampfer = proxon_number(812, scale=10, offset=-1000, min_raw=500, max_raw=2000, unit="°C")
|
|
46
|
+
t20_behaelter_unten = proxon_number(813, scale=10, offset=-1000, min_raw=500, max_raw=2000, unit="°C")
|
|
47
|
+
t21_behaelter_mitte = proxon_number(814, scale=10, offset=-1000, min_raw=500, max_raw=2000, unit="°C")
|
|
48
|
+
t13_kompressor = proxon_number(815, scale=10, offset=-1000, min_raw=500, max_raw=2500, unit="°C")
|
|
49
|
+
t11_sauggas = proxon_number(816, scale=10, offset=-1000, min_raw=500, max_raw=2000, unit="°C")
|
|
50
|
+
t9_extern = proxon_number(817, scale=10, offset=-1000, min_raw=500, max_raw=2000, unit="°C")
|
|
51
|
+
|
|
52
|
+
behaelter_avg = proxon_number(882, scale=100, min_raw=1000, max_raw=10000, unit="°C")
|
|
53
|
+
solltemperatur_akt = proxon_number(879, scale=10, min_raw=200, max_raw=700, unit="°C")
|
|
54
|
+
|
|
55
|
+
# Relais
|
|
56
|
+
r2_kompressor = proxon_number(824)
|
|
57
|
+
r3_solar = proxon_number(825)
|
|
58
|
+
r4_eheiz = proxon_number(826)
|
|
59
|
+
r5_ventilator = proxon_number(827)
|
|
60
|
+
r6_abtau = proxon_number(828)
|
|
61
|
+
|
|
62
|
+
# Ventilator
|
|
63
|
+
ventilator_pct = proxon_number(838, unit="%")
|
|
64
|
+
ventilator_rpm = proxon_number(862, unit="rpm")
|
|
65
|
+
|
|
66
|
+
# PV
|
|
67
|
+
pv_eheiz = proxon_number(899)
|
|
68
|
+
pv_wp = proxon_number(900)
|
|
69
|
+
|
|
70
|
+
fehlerliste = proxon_number(861)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class T300Holding(Component):
|
|
74
|
+
"""Schreibbare Register der T300 (FC3/FC6)."""
|
|
75
|
+
|
|
76
|
+
register_space = "holding"
|
|
77
|
+
max_span = _T300_HOLDING_SPAN
|
|
78
|
+
|
|
79
|
+
solltemperatur = proxon_number(2000, scale=10, min_raw=200, max_raw=550, writable=True, unit="°C")
|
|
80
|
+
eheiz_freigabe = proxon_number(2001, min_raw=0, max_raw=1, writable=True)
|
|
81
|
+
betriebsart = proxon_number(2002, min_raw=0, max_raw=3, writable=True)
|
|
82
|
+
temp_eheiz = proxon_number(2003, scale=10, min_raw=200, max_raw=700, writable=True, unit="°C")
|
|
83
|
+
pv_funktion = proxon_number(2010, min_raw=0, max_raw=1, writable=True)
|
|
84
|
+
legionella = proxon_number(2025, min_raw=0, max_raw=1, writable=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class T300:
|
|
88
|
+
"""Beide Register-Räume der T300 hinter einer Fassade."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, unit: ModbusUnit) -> None:
|
|
91
|
+
self.inputs = T300Input(unit)
|
|
92
|
+
self.holding = T300Holding(unit)
|
|
93
|
+
|
|
94
|
+
async def async_update(self) -> None:
|
|
95
|
+
await self.inputs.async_update()
|
|
96
|
+
await self.holding.async_update()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Gerätefassade: Freischaltung, Teilausfall, Schreibweg."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from modbus_connection import ModbusError
|
|
7
|
+
from modbus_connection.mock import MockModbusConnection
|
|
8
|
+
|
|
9
|
+
from proxon_modbus.device import (
|
|
10
|
+
UNLOCK_REGISTER,
|
|
11
|
+
UNLOCK_VALUE,
|
|
12
|
+
ProxonDevice,
|
|
13
|
+
ProxonUnreachable,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _seed(unit):
|
|
18
|
+
"""Genug Register, dass jede Baugruppe etwas zu lesen findet."""
|
|
19
|
+
for addr in list(range(0, 52)) + list(range(154, 266)) + list(range(590, 611)):
|
|
20
|
+
unit.input[addr] = 0
|
|
21
|
+
for addr in list(range(811, 901)):
|
|
22
|
+
unit.input[addr] = 1000
|
|
23
|
+
for addr in (
|
|
24
|
+
list(range(16, 23)) + list(range(41, 144)) + [187]
|
|
25
|
+
+ list(range(213, 260)) + [438, 460] + list(range(467, 470))
|
|
26
|
+
+ list(range(613, 620)) + list(range(620, 700)) + list(range(2000, 2026))
|
|
27
|
+
):
|
|
28
|
+
unit.holding[addr] = 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture
|
|
32
|
+
def conn():
|
|
33
|
+
c = MockModbusConnection()
|
|
34
|
+
_seed(c.for_unit(41))
|
|
35
|
+
return c
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def unit(conn):
|
|
40
|
+
return conn.for_unit(41)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def test_freischaltung_wird_einmal_geschrieben(unit):
|
|
44
|
+
writes = []
|
|
45
|
+
unit.on_write(lambda ev: writes.append(ev))
|
|
46
|
+
|
|
47
|
+
dev = ProxonDevice(unit)
|
|
48
|
+
await dev.async_update()
|
|
49
|
+
await dev.async_update()
|
|
50
|
+
await dev.async_update()
|
|
51
|
+
|
|
52
|
+
unlocks = [w for w in writes if getattr(w, "address", None) == UNLOCK_REGISTER]
|
|
53
|
+
assert len(unlocks) == 1, f"Freischaltung mehrfach geschrieben: {unlocks}"
|
|
54
|
+
assert unlocks[0].values == [UNLOCK_VALUE]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def test_freischaltung_nach_verbindungsverlust_erneut(conn, unit):
|
|
58
|
+
writes = []
|
|
59
|
+
unit.on_write(lambda ev: writes.append(ev))
|
|
60
|
+
|
|
61
|
+
dev = ProxonDevice(unit)
|
|
62
|
+
await dev.async_update()
|
|
63
|
+
conn.simulate_connection_lost()
|
|
64
|
+
await dev.async_update()
|
|
65
|
+
|
|
66
|
+
unlocks = [w for w in writes if getattr(w, "address", None) == UNLOCK_REGISTER]
|
|
67
|
+
assert len(unlocks) == 2, "nach Reconnect muss neu freigeschaltet werden"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def test_teilausfall_behaelt_alte_werte(unit):
|
|
71
|
+
"""Ein kaputter Block darf den Rest nicht mitreißen."""
|
|
72
|
+
unit.input[263] = 2150
|
|
73
|
+
dev = ProxonDevice(unit)
|
|
74
|
+
await dev.async_update()
|
|
75
|
+
assert dev.fwt_input.t21_zone1 == 21.5
|
|
76
|
+
|
|
77
|
+
# T300-Input scheitert ab jetzt, FWT bleibt lesbar
|
|
78
|
+
unit.fail_read(811, ModbusError("Bus belegt"), register_type="input")
|
|
79
|
+
unit.input[263] = 2200
|
|
80
|
+
failed = await dev.async_update()
|
|
81
|
+
|
|
82
|
+
assert "T300Input" in failed
|
|
83
|
+
assert dev.fwt_input.t21_zone1 == 22.0 # FWT weiter aktuell
|
|
84
|
+
assert dev.t300_input.t21_behaelter_mitte is not None # alter Wert bleibt
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_kompletter_ausfall_wirft(unit):
|
|
88
|
+
unit.fail_requests(ModbusError("Adapter weg"))
|
|
89
|
+
dev = ProxonDevice(unit)
|
|
90
|
+
|
|
91
|
+
with pytest.raises(ProxonUnreachable):
|
|
92
|
+
await dev.async_update()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def test_ohne_t300_keine_t300_baugruppen(unit):
|
|
96
|
+
dev = ProxonDevice(unit, has_t300=False)
|
|
97
|
+
await dev.async_update()
|
|
98
|
+
|
|
99
|
+
assert dev.t300_input is None
|
|
100
|
+
assert not any("T300" in type(c).__name__ for c in dev.components)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def test_schreiben_schaltet_vorher_frei(unit):
|
|
104
|
+
writes = []
|
|
105
|
+
unit.on_write(lambda ev: writes.append(ev))
|
|
106
|
+
|
|
107
|
+
dev = ProxonDevice(unit)
|
|
108
|
+
await dev.async_write(dev.fwt_holding, "luefterstufe", 2)
|
|
109
|
+
|
|
110
|
+
adressen = [getattr(w, "address", None) for w in writes]
|
|
111
|
+
assert adressen[0] == UNLOCK_REGISTER, "Freischaltung muss zuerst kommen"
|
|
112
|
+
assert 22 in adressen
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""FWT-Modell gegen echte Registerwerte vom 2026-09-02 (Bridge 10.42.22.2).
|
|
2
|
+
|
|
3
|
+
Mehrere Werte lassen sich gegen die damals in HA angezeigten Entities prüfen:
|
|
4
|
+
Reg 16 = 3 -> select.proxon_fwt_betriebsart == "Komfortbetrieb"
|
|
5
|
+
Reg 19 = 800 -> number.hwr_proxon_fwt_bypass_minimum_frischlufttemperatur == 8.0
|
|
6
|
+
Reg 618 = 1800 -> number.proxon_fwt_nachttemperatur == 18.0
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from modbus_connection.mock import MockModbusConnection
|
|
13
|
+
|
|
14
|
+
from proxon_modbus.fwt import Betriebsart, BypassModus, FwtHolding, FwtInput
|
|
15
|
+
|
|
16
|
+
# input 0+ und 154+ (jeweils die protokollierten ersten Wörter)
|
|
17
|
+
REAL_INPUT = {
|
|
18
|
+
0: 1872, 1: 1804, # Drehzahlen Zu-/Abluft
|
|
19
|
+
154: 3, 155: 4900, 156: 5000, 157: 0, 158: 0, 159: 2,
|
|
20
|
+
}
|
|
21
|
+
# holding 16+, 41+, 460, 467+, 613+
|
|
22
|
+
REAL_HOLDING = {
|
|
23
|
+
16: 3, 17: 0, 18: 1, 19: 800, 20: 900, 21: 0,
|
|
24
|
+
41: 300, 42: 40,
|
|
25
|
+
460: 6,
|
|
26
|
+
467: 34855, 468: 0, 469: 1706,
|
|
27
|
+
613: 0, 618: 1800, 619: 0,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture
|
|
32
|
+
def unit():
|
|
33
|
+
u = MockModbusConnection().for_unit(41)
|
|
34
|
+
u.input.update(REAL_INPUT)
|
|
35
|
+
u.holding.update(REAL_HOLDING)
|
|
36
|
+
return u
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def test_input_echte_werte(unit):
|
|
40
|
+
fwt = FwtInput(unit)
|
|
41
|
+
await fwt.async_update()
|
|
42
|
+
|
|
43
|
+
assert fwt.drehzahl_zuluft == 1872
|
|
44
|
+
assert fwt.drehzahl_abluft == 1804
|
|
45
|
+
assert fwt.stufe_zuluft == 3
|
|
46
|
+
assert fwt.schieber_position == 2
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def test_holding_echte_werte(unit):
|
|
50
|
+
fwt = FwtHolding(unit)
|
|
51
|
+
await fwt.async_update()
|
|
52
|
+
|
|
53
|
+
assert fwt.sollbetriebsart == 3 # == "Komfortbetrieb" in HA
|
|
54
|
+
assert fwt.bypass_min_frischluft == 8.0 # der auf 8 °C gesenkte Sommerbypass
|
|
55
|
+
assert fwt.bypass_modus == 0 # Geregelt (Automatik aktiv)
|
|
56
|
+
assert fwt.nacht_temperatur == 18.0 # == number.proxon_fwt_nachttemperatur
|
|
57
|
+
assert fwt.wp_kuehlschwelle == 3.0
|
|
58
|
+
assert fwt.wp_einschaltschwelle == 0.4
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def test_betriebsart_enum_passt_zu_rohwert(unit):
|
|
62
|
+
fwt = FwtHolding(unit)
|
|
63
|
+
await fwt.async_update()
|
|
64
|
+
assert Betriebsart(int(fwt.sollbetriebsart)) is Betriebsart.KOMFORT
|
|
65
|
+
assert BypassModus(int(fwt.bypass_modus)) is BypassModus.GEREGELT
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def test_filter_resttage(unit):
|
|
69
|
+
"""Standzeit 6 Monate, 1706 h gelaufen -> (4320-1706)/24 = 108,9 Tage."""
|
|
70
|
+
fwt = FwtHolding(unit)
|
|
71
|
+
await fwt.async_update()
|
|
72
|
+
|
|
73
|
+
assert fwt.geraetefilter_standzeit_monate == 6
|
|
74
|
+
assert fwt.geraetefilter_stunden == 1706
|
|
75
|
+
assert fwt.geraetefilter_remaining_days == 108.9
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def test_resttage_none_wenn_quelle_fehlt(unit):
|
|
79
|
+
"""Ohne Unlock (Reg 438) liefern 460/469 nichts — dann kein Fantasiewert."""
|
|
80
|
+
u = MockModbusConnection().for_unit(41)
|
|
81
|
+
u.holding.update({k: v for k, v in REAL_HOLDING.items() if k not in (460, 469)})
|
|
82
|
+
fwt = FwtHolding(u)
|
|
83
|
+
await fwt.async_update()
|
|
84
|
+
|
|
85
|
+
assert fwt.geraetefilter_remaining_days is None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize(
|
|
89
|
+
"bit,erwartet", [(0b0000, False), (0b0010, True), (0b0011, True), (0b0001, False)]
|
|
90
|
+
)
|
|
91
|
+
async def test_filterwechsel_aus_fehlerstatus1(unit, bit, erwartet):
|
|
92
|
+
"""Bit 1 aus Reg 48 — abgeleitet, nicht als eigenes Register gelesen."""
|
|
93
|
+
unit.input[48] = bit
|
|
94
|
+
fwt = FwtInput(unit)
|
|
95
|
+
await fwt.async_update()
|
|
96
|
+
|
|
97
|
+
assert fwt.filter_wechsel_faellig is erwartet
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def test_negative_temperatur_wird_signiert_gelesen(unit):
|
|
101
|
+
"""int16: Frischluft unter 0 °C darf nicht als 655 °C erscheinen."""
|
|
102
|
+
unit.holding[618] = 0x10000 - 500 # -5,00 °C
|
|
103
|
+
fwt = FwtHolding(unit)
|
|
104
|
+
await fwt.async_update()
|
|
105
|
+
|
|
106
|
+
# -500 liegt außerhalb min_raw=1000 -> Bereichsfilter greift, kein Unsinnwert
|
|
107
|
+
assert fwt.nacht_temperatur is None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def test_bereichsfilter_auf_zonentemperatur(unit):
|
|
111
|
+
"""6000 = 60 °C ist die Obergrenze; darüber ist es ein Fremdframe."""
|
|
112
|
+
unit.input[263] = 6001
|
|
113
|
+
fwt = FwtInput(unit)
|
|
114
|
+
await fwt.async_update()
|
|
115
|
+
assert fwt.t21_zone1 is None
|
|
116
|
+
|
|
117
|
+
unit.input[263] = 2150
|
|
118
|
+
fwt2 = FwtInput(unit)
|
|
119
|
+
await fwt2.async_update()
|
|
120
|
+
assert fwt2.t21_zone1 == 21.5
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""NBE-Wiederholgruppen und Raumnamen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from modbus_connection.mock import MockModbusConnection
|
|
7
|
+
|
|
8
|
+
from proxon_modbus.nbe import (
|
|
9
|
+
NbeSettingsBank,
|
|
10
|
+
NbeTemperatures,
|
|
11
|
+
RoomNames,
|
|
12
|
+
is_configured_room,
|
|
13
|
+
physical_index,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _pack(text: str, regs: int = 10) -> dict[int, int]:
|
|
18
|
+
"""Namen in Register packen, wie die Anlage sie ablegt."""
|
|
19
|
+
width = regs * 2
|
|
20
|
+
raw = text.encode("latin-1").ljust(width, b"\x00")
|
|
21
|
+
return {i // 2: (raw[i] << 8) | raw[i + 1] for i in range(0, width, 2)}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def unit():
|
|
26
|
+
return MockModbusConnection().for_unit(41)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def test_temperaturen_liegen_im_dreierschritt(unit):
|
|
30
|
+
"""Gerät n liest 590 + n*3 — nicht 590 + n."""
|
|
31
|
+
for n in range(7):
|
|
32
|
+
unit.input[590 + n * 3] = 200 + n * 10 # 20,0 °C aufsteigend
|
|
33
|
+
comp = NbeTemperatures(unit)
|
|
34
|
+
await comp.async_update()
|
|
35
|
+
|
|
36
|
+
werte = [d.temperatur for d in comp.devices]
|
|
37
|
+
assert werte == [20.0, 21.0, 22.0, 23.0, 24.0, 25.0, 26.0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def test_settings_drei_parallele_reihen(unit):
|
|
41
|
+
"""Offset/Mittel/PTC liegen 20 Register auseinander, Schrittweite 1."""
|
|
42
|
+
for n in range(7):
|
|
43
|
+
unit.holding[213 + n] = 0xFFFF if n == 0 else n # n=0 → -1 K
|
|
44
|
+
unit.holding[233 + n] = 20 + n
|
|
45
|
+
unit.holding[253 + n] = n % 2
|
|
46
|
+
comp = NbeSettingsBank(unit)
|
|
47
|
+
await comp.async_update()
|
|
48
|
+
|
|
49
|
+
assert comp.devices[0].offset_k == -1
|
|
50
|
+
assert comp.devices[3].offset_k == 3
|
|
51
|
+
assert [d.mitteltemperatur for d in comp.devices] == [20, 21, 22, 23, 24, 25, 26]
|
|
52
|
+
assert [d.ptc_freigabe for d in comp.devices] == [0, 1, 0, 1, 0, 1, 0]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def test_offset_ausserhalb_bereich_wird_none(unit):
|
|
56
|
+
"""-10…10 K ist der gültige Bereich; alles andere ist Busmüll."""
|
|
57
|
+
for n in range(7):
|
|
58
|
+
unit.holding[213 + n] = 0
|
|
59
|
+
unit.holding[233 + n] = 20
|
|
60
|
+
unit.holding[253 + n] = 0
|
|
61
|
+
unit.holding[213 + 2] = 99
|
|
62
|
+
comp = NbeSettingsBank(unit)
|
|
63
|
+
await comp.async_update()
|
|
64
|
+
|
|
65
|
+
assert comp.devices[2].offset_k is None
|
|
66
|
+
assert comp.devices[1].offset_k == 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def test_raumnamen_latin1(unit):
|
|
70
|
+
"""Umlaute müssen durchkommen — StringField der Lib kann das nicht."""
|
|
71
|
+
for base, text in ((620, "Wohnen/Essen"), (630, "Küche"), (640, "Büro")):
|
|
72
|
+
for rel, word in _pack(text).items():
|
|
73
|
+
unit.holding[base + rel] = word
|
|
74
|
+
comp = RoomNames(unit)
|
|
75
|
+
await comp.async_update()
|
|
76
|
+
|
|
77
|
+
assert comp.slots[0].name == "Wohnen/Essen"
|
|
78
|
+
assert comp.slots[1].name == "Küche"
|
|
79
|
+
assert comp.slots[2].name == "Büro"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def test_namen_slots_im_zehnerschritt(unit):
|
|
83
|
+
"""Slot 7 liegt bei 620 + 7*10 = 690."""
|
|
84
|
+
for rel, word in _pack("Dachzimmer").items():
|
|
85
|
+
unit.holding[690 + rel] = word
|
|
86
|
+
comp = RoomNames(unit)
|
|
87
|
+
await comp.async_update()
|
|
88
|
+
|
|
89
|
+
assert comp.slots[7].name == "Dachzimmer"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_werksnamen_gelten_nicht_als_konfiguriert():
|
|
93
|
+
assert is_configured_room("Küche")
|
|
94
|
+
assert not is_configured_room("Raum 1")
|
|
95
|
+
assert not is_configured_room("raum 3")
|
|
96
|
+
assert not is_configured_room("")
|
|
97
|
+
assert not is_configured_room(None)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_slot_zu_geraet_versatz():
|
|
101
|
+
"""Slot 0 = HBDE ohne Gerät; Slot 1 ist Gerät 0."""
|
|
102
|
+
assert physical_index(0) is None
|
|
103
|
+
assert physical_index(1) == 0
|
|
104
|
+
assert physical_index(7) == 6
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""T300-Modell gegen echte Registerwerte.
|
|
2
|
+
|
|
3
|
+
Die Rohwerte unten stammen aus dem Lauf gegen die echte Anlage am 2026-09-02
|
|
4
|
+
(USR-Bridge 10.42.22.2:502, Slave 41) — nicht ausgedacht. `t21_behaelter_mitte`
|
|
5
|
+
ist zusätzlich gegen den damals in HA angezeigten Wert (57,5 °C) abgeglichen.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from modbus_connection.mock import MockModbusConnection
|
|
12
|
+
|
|
13
|
+
from proxon_modbus import T300
|
|
14
|
+
|
|
15
|
+
# Gelesen wurden die Blöcke input 811+90 und holding 2000+26; protokolliert sind
|
|
16
|
+
# jeweils die ersten sechs Wörter, also die Adressen 811–816 bzw. 2000–2005.
|
|
17
|
+
# Nur diese stehen hier — 2010 und 2025 wurden nicht einzeln festgehalten und
|
|
18
|
+
# werden deshalb nicht als Messwerte ausgegeben.
|
|
19
|
+
REAL_INPUT = {811: 1218, 812: 1224, 813: 1403, 814: 1575, 815: 1348, 816: 1316}
|
|
20
|
+
REAL_HOLDING = {2000: 520, 2001: 0, 2002: 1, 2003: 590}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _seed(unit, holding=None, inputs=None):
|
|
24
|
+
unit.input.update(inputs if inputs is not None else REAL_INPUT)
|
|
25
|
+
unit.holding.update(holding if holding is not None else REAL_HOLDING)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def unit():
|
|
30
|
+
return MockModbusConnection().for_unit(41)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def test_temperaturen_echte_werte(unit):
|
|
34
|
+
"""Offset-vor-Skalierung korrekt umgerechnet."""
|
|
35
|
+
_seed(unit)
|
|
36
|
+
t300 = T300(unit)
|
|
37
|
+
await t300.async_update()
|
|
38
|
+
|
|
39
|
+
assert t300.inputs.t21_behaelter_mitte == 57.5 # == HA-Sensor am 02.09.
|
|
40
|
+
assert t300.inputs.t5_vorverdampfer == 21.8
|
|
41
|
+
assert t300.inputs.t6_verdampfer == 22.4
|
|
42
|
+
assert t300.inputs.t20_behaelter_unten == 40.3
|
|
43
|
+
assert t300.inputs.t13_kompressor == 34.8
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def test_holding_echte_werte(unit):
|
|
47
|
+
_seed(unit)
|
|
48
|
+
t300 = T300(unit)
|
|
49
|
+
await t300.async_update()
|
|
50
|
+
|
|
51
|
+
assert t300.holding.solltemperatur == 52.0
|
|
52
|
+
assert t300.holding.temp_eheiz == 59.0 # Boost-Ziel, wie dokumentiert
|
|
53
|
+
assert t300.holding.eheiz_freigabe == 0
|
|
54
|
+
assert t300.holding.betriebsart == 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def test_naive_portierung_waere_falsch(unit):
|
|
58
|
+
"""Absicherung gegen den Offset-Fehler: roh*0.1-1000 ergäbe -842.5."""
|
|
59
|
+
_seed(unit)
|
|
60
|
+
t300 = T300(unit)
|
|
61
|
+
await t300.async_update()
|
|
62
|
+
|
|
63
|
+
assert t300.inputs.t21_behaelter_mitte != pytest.approx(-842.5)
|
|
64
|
+
assert 0 < t300.inputs.t21_behaelter_mitte < 100
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.parametrize("garbage", [0, 499, 2001, 65535])
|
|
68
|
+
async def test_bereichsfilter_verwirft_muell(unit, garbage):
|
|
69
|
+
"""Fremdframes der USR-Bridge (z. B. 128 °C Warmwasser) -> None statt Unsinn."""
|
|
70
|
+
_seed(unit, inputs={**REAL_INPUT, 814: garbage})
|
|
71
|
+
t300 = T300(unit)
|
|
72
|
+
await t300.async_update()
|
|
73
|
+
|
|
74
|
+
assert t300.inputs.t21_behaelter_mitte is None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def test_gueltige_randwerte_bleiben(unit):
|
|
78
|
+
"""Die Grenzen selbst sind gültig — kein Off-by-one im Filter."""
|
|
79
|
+
for raw, expected in ((500, -50.0), (2000, 100.0)):
|
|
80
|
+
u = MockModbusConnection().for_unit(41)
|
|
81
|
+
_seed(u, inputs={**REAL_INPUT, 814: raw})
|
|
82
|
+
t300 = T300(u)
|
|
83
|
+
await t300.async_update()
|
|
84
|
+
assert t300.inputs.t21_behaelter_mitte == expected
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_ein_request_pro_raum(unit):
|
|
88
|
+
"""Der Planer soll je Raum einen Block lesen, nicht pro Feld einen."""
|
|
89
|
+
_seed(unit)
|
|
90
|
+
t300 = T300(unit)
|
|
91
|
+
await t300.async_update()
|
|
92
|
+
|
|
93
|
+
inputs = [e for e in unit.read_events if e.register_type == "input"]
|
|
94
|
+
holding = [e for e in unit.read_events if e.register_type == "holding"]
|
|
95
|
+
assert len(inputs) == 1, unit.read_events
|
|
96
|
+
assert len(holding) == 1, unit.read_events
|