@camstack/addon-provider-bosch 0.1.50 → 0.1.52

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,258 @@
1
+ """The worker's normalization of a `homecom_alt` K40 reading, and its stdin contract.
2
+
3
+ The fixture is shaped like the library's `BHCDeviceK40` (as `dataclasses.asdict` gives it),
4
+ with the values a live K40 air/water heat pump answered. Runs under pytest, or plainly:
5
+ `python3 test_homecom_worker.py`. Neither needs `homecom_alt` installed.
6
+ """
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ HERE = Path(__file__).resolve().parent
13
+ sys.path.insert(0, str(HERE))
14
+
15
+ from homecom_normalize import normalize_reading, probe_outcome # noqa: E402
16
+ from homecom_worker import WorkerError, parse_request # noqa: E402
17
+
18
+ FORBIDDEN = {} # the library turns a 403/404 into an empty dict
19
+
20
+
21
+ def env(value, **extra):
22
+ return {"id": "/x", "type": "stringValue", "value": value, **extra}
23
+
24
+
25
+ def k40_device():
26
+ return {
27
+ "device": "101000000",
28
+ "notifications": [],
29
+ "holiday_mode": {"id": "/holidayMode/activeModes", "values": [], "allowedValues": []},
30
+ "away_mode": env("off", allowedValues=["on", "off"], writeable=1),
31
+ "power_limitation": FORBIDDEN,
32
+ "outdoor_temp": env(7.5, type="floatValue", unitOfMeasure="C"),
33
+ "heat_sources": {
34
+ "pumpType": env("air_water"),
35
+ "starts": {"id": "/heatSources/hs1/numberOfStarts", "type": "emonValue",
36
+ "values": [{"ch": 400}, {"total": 516}]},
37
+ "returnTemperature": env(47.8, type="floatValue"),
38
+ "actualSupplyTemperature": env(52.2, type="floatValue"),
39
+ "actualModulation": env(89, type="floatValue"),
40
+ "systemPressure": env(1.7, type="floatValue"),
41
+ "consumption": FORBIDDEN,
42
+ "totalWorkingTime": FORBIDDEN,
43
+ },
44
+ "dhw_circuits": [
45
+ {
46
+ "id": "/dhwCircuits/dhw1",
47
+ "operationMode": env("eco", allowedValues=["Off", "low", "high", "ownprogram", "eco"],
48
+ writeable=1),
49
+ "actualTemp": env(48.0, type="floatValue"),
50
+ "charge": env("stop", allowedValues=["start", "stop"], writeable=1),
51
+ "chargeRemainingTime": env(0, type="floatValue"),
52
+ "currentTemperatureLevel": env("eco", allowedValues=["off", "low", "high", "eco"],
53
+ writeable=0),
54
+ "singleChargeSetpoint": env(60, type="floatValue", minValue=50, maxValue=70),
55
+ "tempLevel": {"eco": env(51, type="floatValue", minValue=30, maxValue=60),
56
+ "low": FORBIDDEN},
57
+ }
58
+ ],
59
+ "heating_circuits": [
60
+ {
61
+ "id": "/heatingCircuits/hc1",
62
+ "operationMode": env("manual", allowedValues=["off", "manual", "auto"], writeable=1),
63
+ "currentSuWiMode": env("forced"),
64
+ "suWiSwitchMode": FORBIDDEN,
65
+ "heatCoolMode": env("heat", allowedValues=["heat", "cool", "heatCool"], writeable=0),
66
+ "roomTemp": env(-3276.8, type="floatValue", state=[{"open": -3276.8}, {"short": 3276.7}]),
67
+ "actualHumidity": FORBIDDEN,
68
+ "manualRoomSetpoint": env(30, type="floatValue", minValue=5, maxValue=30, writeable=1),
69
+ "currentRoomSetpoint": env(30, type="floatValue"),
70
+ "maxFlowTemp": env(60, type="floatValue", minValue=30, maxValue=60, writeable=1),
71
+ "temperatureLevelComfort2": None,
72
+ },
73
+ {"id": "/heatingCircuits/hc2"},
74
+ ],
75
+ }
76
+
77
+
78
+ def extras():
79
+ return {
80
+ "silentMode": FORBIDDEN,
81
+ "additionalHeaterMode": FORBIDDEN,
82
+ "aggregateStarts": env(516, type="floatValue"),
83
+ "chargeDuration": {"dhw1": env(60, type="floatValue", minValue=60, maxValue=2880)},
84
+ }
85
+
86
+
87
+ def test_heat_source_values_come_from_the_k40_keys():
88
+ hs = normalize_reading(k40_device(), extras())["heatSource"]
89
+ assert hs["heatPumpType"] == "air_water"
90
+ assert hs["supplyTemp"] == 52.2
91
+ assert hs["returnTemp"] == 47.8
92
+ assert hs["modulationPct"] == 89
93
+ assert hs["pressureBar"] == 1.7
94
+ assert hs["starts"] == 516
95
+ assert hs["outdoorTemp"] == 7.5
96
+
97
+
98
+ def test_a_forbidden_resource_is_null_never_zero():
99
+ reading = normalize_reading(k40_device(), extras())
100
+ assert reading["heatSource"]["thermalKwh"] is None
101
+ assert reading["heatSource"]["electricalKwh"] is None
102
+ assert reading["system"]["silent"] is None
103
+ assert reading["system"]["additionalHeater"] is None
104
+ hc1 = reading["heatingCircuits"][0]
105
+ assert hc1["suWiSwitchMode"] is None
106
+ assert hc1["humidity"] is None
107
+ assert hc1["comfortSetpoint"] is None
108
+
109
+
110
+ def test_a_sensor_fault_marker_is_null():
111
+ device = k40_device()
112
+ device["heat_sources"]["returnTemperature"] = env(
113
+ 3276.7, type="floatValue", state=[{"open": -3276.8}, {"short": 3276.7}]
114
+ )
115
+ reading = normalize_reading(device, extras())
116
+ assert reading["heatSource"]["returnTemp"] is None
117
+ assert reading["heatingCircuits"][0]["roomTemp"] is None
118
+
119
+
120
+ def test_energy_is_split_from_the_emon_parts_and_electrical_needs_both_parts():
121
+ device = k40_device()
122
+ device["heat_sources"]["consumption"] = {
123
+ "type": "emonValue", "values": [{"outputProduced": 9000.5}, {"eheater": 12}, {"compressor": 2500}],
124
+ }
125
+ hs = normalize_reading(device, extras())["heatSource"]
126
+ assert hs["thermalKwh"] == 9000.5
127
+ assert hs["electricalKwh"] == 2512
128
+ device["heat_sources"]["consumption"] = {"type": "emonValue", "values": [{"compressor": 2500}]}
129
+ assert normalize_reading(device, extras())["heatSource"]["electricalKwh"] is None
130
+
131
+
132
+ def test_starts_fall_back_to_the_aggregate_when_hs1_has_no_total():
133
+ device = k40_device()
134
+ device["heat_sources"]["starts"] = FORBIDDEN
135
+ assert normalize_reading(device, extras())["heatSource"]["starts"] == 516
136
+ assert normalize_reading(device, {})["heatSource"]["starts"] is None
137
+
138
+
139
+ def test_circuits_keep_their_settings_with_limits_and_allowed_values():
140
+ reading = normalize_reading(k40_device(), extras())
141
+ assert [c["id"] for c in reading["heatingCircuits"]] == ["hc1", "hc2"]
142
+ hc1 = reading["heatingCircuits"][0]
143
+ assert hc1["operationMode"] == {"value": "manual", "min": None, "max": None,
144
+ "allowed": ["off", "manual", "auto"], "writeable": True}
145
+ assert hc1["heatCoolMode"]["writeable"] is False
146
+ assert hc1["manualRoomSetpoint"]["min"] == 5 and hc1["manualRoomSetpoint"]["max"] == 30
147
+ assert hc1["maxFlowTemp"]["value"] == 60
148
+ assert reading["heatingCircuits"][1]["operationMode"] is None
149
+
150
+
151
+ def test_a_heating_circuit_keeps_the_name_the_gateway_gave():
152
+ device = k40_device()
153
+ device["heating_circuits"][0]["name"] = "Living room"
154
+ circuits = normalize_reading(device, extras())["heatingCircuits"]
155
+ assert circuits[0]["name"] == "Living room"
156
+ assert circuits[1]["name"] is None
157
+
158
+
159
+ def test_a_heat_source_probe_separates_refused_from_absent_and_transient():
160
+ assert [probe_outcome(s) for s in (200, 403, 500, 404, 429, 503, 502)] == [
161
+ "answered", "refused", "refused", "absent", "transient", "transient", "transient"
162
+ ]
163
+ probe = {"/heatSources/hs1/heatPumpType": "refused", "/x": "bogus", 3: "absent"}
164
+ reading = normalize_reading(k40_device(), {**extras(), "heatSourceProbe": probe})
165
+ assert reading["heatSourceProbe"] == {"/heatSources/hs1/heatPumpType": "refused"}
166
+ assert normalize_reading(k40_device(), extras())["heatSourceProbe"] == {}
167
+
168
+
169
+ def test_setpoint_limits_come_only_from_the_gateway_answer():
170
+ device = k40_device()
171
+ hc1 = device["heating_circuits"][0]
172
+ hc1["manualRoomSetpoint"] = env(30, type="floatValue", writeable=1)
173
+ hc1["temperatureLevelComfort2"] = env(22, type="floatValue", minValue=15, maxValue=28, writeable=1)
174
+ hc1["temperatureLevelEco"] = env(19, type="floatValue", minValue=10, maxValue=25, writeable=1)
175
+ out = normalize_reading(device, extras())["heatingCircuits"][0]
176
+ assert out["manualRoomSetpoint"]["min"] is None and out["manualRoomSetpoint"]["max"] is None
177
+ assert (out["comfortSetpoint"]["value"], out["comfortSetpoint"]["min"], out["comfortSetpoint"]["max"]) == (22, 15, 28)
178
+ assert (out["ecoSetpoint"]["value"], out["ecoSetpoint"]["min"], out["ecoSetpoint"]["max"]) == (19, 10, 25)
179
+
180
+
181
+ def test_dhw_carries_levels_charge_and_the_separately_read_duration():
182
+ dhw1 = normalize_reading(k40_device(), extras())["dhwCircuits"][0]
183
+ assert dhw1["id"] == "dhw1"
184
+ assert dhw1["actualTemp"] == 48.0
185
+ assert dhw1["charge"]["value"] == "stop"
186
+ assert dhw1["chargeDuration"]["max"] == 2880
187
+ assert dhw1["currentTemperatureLevel"]["value"] == "eco"
188
+ assert dhw1["currentSetpoint"] is None
189
+ assert list(dhw1["temperatureLevels"]) == ["eco"]
190
+ assert dhw1["temperatureLevels"]["eco"]["value"] == 51
191
+
192
+
193
+ def test_holiday_answered_with_no_active_mode_is_an_empty_value_not_null():
194
+ reading = normalize_reading(k40_device(), extras())
195
+ assert reading["system"]["holiday"]["value"] == ""
196
+ assert reading["system"]["away"]["value"] == "off"
197
+
198
+
199
+ def test_icom_aliases_are_read():
200
+ device = {
201
+ "heat_sources": {
202
+ "type": env("Heatpump"),
203
+ "supplyTemperature": env(40.0, type="floatValue"),
204
+ "modulation": env(50, type="floatValue"),
205
+ "numberOfStarts": env(12, type="floatValue"),
206
+ "outdoorTemp": env(3.0, type="floatValue"),
207
+ },
208
+ "heating_circuits": [{"id": "/heatingCircuits/hc1", "roomtemperature": env(20.5, type="floatValue")}],
209
+ "dhw_circuits": [{"id": "/dhwCircuits/dhw1", "chargeDuration": env(90, type="floatValue"),
210
+ "currentSetpoint": env(52, type="floatValue")}],
211
+ }
212
+ reading = normalize_reading(device, {})
213
+ assert reading["heatSource"]["heatPumpType"] == "Heatpump"
214
+ assert reading["heatSource"]["supplyTemp"] == 40.0
215
+ assert reading["heatSource"]["modulationPct"] == 50
216
+ assert reading["heatSource"]["starts"] == 12
217
+ assert reading["heatSource"]["outdoorTemp"] == 3.0
218
+ assert reading["heatingCircuits"][0]["roomTemp"] == 20.5
219
+ assert reading["dhwCircuits"][0]["chargeDuration"]["value"] == 90
220
+ assert reading["dhwCircuits"][0]["currentSetpoint"]["value"] == 52
221
+
222
+
223
+ def test_the_request_is_validated_before_the_library_loads():
224
+ good = {"token": "t", "gatewayId": "g", "deviceType": "k40", "action": "read"}
225
+ assert parse_request(json.dumps(good))["action"] == "read"
226
+ for bad in (
227
+ "not json",
228
+ json.dumps({**good, "token": ""}),
229
+ json.dumps({**good, "deviceType": "rac"}),
230
+ json.dumps({**good, "action": "write", "write": {"op": "rm -rf"}}),
231
+ ):
232
+ try:
233
+ parse_request(bad)
234
+ except WorkerError as err:
235
+ assert err.kind == "bad-request"
236
+ else:
237
+ raise AssertionError(f"accepted {bad}")
238
+
239
+
240
+ def test_a_bad_request_answers_one_json_line_without_echoing_the_token():
241
+ secret = "secret-token-value"
242
+ proc = subprocess.run(
243
+ [sys.executable, str(HERE / "homecom_worker.py")],
244
+ input=json.dumps({"token": secret, "gatewayId": "g", "deviceType": "rac", "action": "read"}),
245
+ capture_output=True, text=True, timeout=30,
246
+ )
247
+ assert proc.returncode == 1
248
+ answer = json.loads(proc.stdout.strip())
249
+ assert answer["ok"] is False and answer["kind"] == "bad-request"
250
+ assert secret not in proc.stdout and secret not in proc.stderr
251
+
252
+
253
+ if __name__ == "__main__":
254
+ tests = [(name, fn) for name, fn in sorted(globals().items()) if name.startswith("test_")]
255
+ for name, fn in tests:
256
+ fn()
257
+ print(f"ok {name}")
258
+ print(f"{len(tests)} passed")