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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ dist/
6
+ build/
7
+ *.egg-info/
@@ -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