python-hotspring 1.0.0__py3-none-any.whl

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.
hotspring/models.py ADDED
@@ -0,0 +1,762 @@
1
+ """Models for Hot Spring Connected Spa Kit 2."""
2
+ # mypy: disable-error-code="union-attr, arg-type, call-overload, attr-defined"
3
+ # Rationale: This module parses nested JSON dicts typed as dict[str, object].
4
+ # The .get() return type is `object`, which mypy cannot narrow without runtime
5
+ # isinstance checks on every access. This is a known typing limitation for
6
+ # hand-parsed JSON; schema libraries (mashumaro, pydantic) solve this at the
7
+ # cost of an extra dependency.
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from .const import (
14
+ BrightnessLevel,
15
+ HeatingMode,
16
+ JetSpeed,
17
+ LightColor,
18
+ LightWheelMode,
19
+ SpaFailureState,
20
+ TemperatureUnit,
21
+ )
22
+
23
+
24
+ class Spa:
25
+ """Object holding all information from a Hot Spring Spa.
26
+
27
+ This is the top-level container that aggregates all sub-models
28
+ parsed from the various API endpoints.
29
+ """
30
+
31
+ info: SpaInfo
32
+ heater: Heater
33
+ jets: list[Jet]
34
+ blower: Blower
35
+ light_zones: list[LightZone]
36
+ logo_light: LogoLight
37
+ clean_cycle: CleanCycle
38
+ spa_lock: SpaLock
39
+ water_care: WaterCare
40
+ freshwater_iq: FreshWaterIQ
41
+ energy_savings: list[EnergySaving]
42
+ versions: Versions
43
+ connection_status: ConnectionStatus
44
+ diagnostics: Diagnostics
45
+
46
+ def __init__(self, data: dict[str, object]) -> None:
47
+ """Initialize a Spa from the full API response.
48
+
49
+ Args:
50
+ ----
51
+ data: The full API response from a GET /status call.
52
+
53
+ """
54
+ self.update_from_dict(data)
55
+
56
+ def update_from_dict(self, data: dict[str, object]) -> Spa:
57
+ """Update the Spa object from a /status API response.
58
+
59
+ Args:
60
+ ----
61
+ data: The full JSON response from GET /status.
62
+
63
+ Returns:
64
+ -------
65
+ The updated Spa object.
66
+
67
+ """
68
+ self.heater = Heater.from_dict(data.get("heater", {}))
69
+ self.jets = Jet.list_from_dict(data.get("JET", {}))
70
+ self.blower = Blower.from_dict(data.get("blower", {}))
71
+ self.light_zones = LightZone.list_from_dict(data.get("lights", {}))
72
+ self.logo_light = LogoLight.from_dict(data.get("logoLight", {}))
73
+ self.clean_cycle = CleanCycle.from_dict(data.get("cleanCycle", {}))
74
+ self.spa_lock = SpaLock.from_dict(data.get("spaLock", {}))
75
+ self.water_care = WaterCare.from_dict(data.get("waterCare", {}))
76
+ self.freshwater_iq = FreshWaterIQ.from_dict(data.get("FWIQ_Parameters", {}))
77
+ self.energy_savings = EnergySaving.list_from_dict(data.get("energySavings", {}))
78
+ self.versions = Versions.from_dict(
79
+ data.get("productVersions", {}).get("status", {})
80
+ )
81
+ # These are populated by separate API calls
82
+ if not hasattr(self, "info"):
83
+ self.info = SpaInfo.from_dict({})
84
+ if not hasattr(self, "connection_status"):
85
+ self.connection_status = ConnectionStatus.from_dict({})
86
+ if not hasattr(self, "diagnostics"):
87
+ self.diagnostics = Diagnostics.from_dict({})
88
+
89
+ return self
90
+
91
+ def update_info(self, data: dict[str, object]) -> None:
92
+ """Update spa identity from /startup and /spamodel responses.
93
+
94
+ Args:
95
+ ----
96
+ data: Combined data from /startup and /spamodel endpoints.
97
+
98
+ """
99
+ self.info = SpaInfo.from_dict(data)
100
+
101
+ def update_connection_status(self, data: dict[str, object]) -> None:
102
+ """Update connection status from /spaConnectStatus response.
103
+
104
+ Args:
105
+ ----
106
+ data: The JSON response from GET /spaConnectStatus.
107
+
108
+ """
109
+ self.connection_status = ConnectionStatus.from_dict(data)
110
+
111
+ def update_diagnostics(self, data: dict[str, object]) -> None:
112
+ """Update diagnostics from /addDebugData response.
113
+
114
+ Args:
115
+ ----
116
+ data: The JSON response from GET /addDebugData.
117
+
118
+ """
119
+ self.diagnostics = Diagnostics.from_dict(data)
120
+
121
+ def update_freshwater_iq(self, data: dict[str, object]) -> None:
122
+ """Update FreshWater IQ data from /getFWIQData response.
123
+
124
+ Args:
125
+ ----
126
+ data: The JSON response from GET /getFWIQData.
127
+
128
+ """
129
+ self.freshwater_iq = FreshWaterIQ.from_dict(data)
130
+
131
+
132
+ @dataclass
133
+ class SpaInfo:
134
+ """Spa identity and configuration information.
135
+
136
+ Populated from the /startup and /spamodel endpoints.
137
+ """
138
+
139
+ hostname: str
140
+ mac_address: str
141
+ model: str
142
+ ssid: str
143
+ sna_ready: bool
144
+
145
+ @staticmethod
146
+ def from_dict(data: dict[str, object]) -> SpaInfo:
147
+ """Create a SpaInfo from API response data.
148
+
149
+ Args:
150
+ ----
151
+ data: Combined data from /startup and /spamodel.
152
+
153
+ Returns:
154
+ -------
155
+ A SpaInfo instance.
156
+
157
+ """
158
+ return SpaInfo(
159
+ hostname=data.get("HOSTNAME", ""),
160
+ mac_address=data.get("MAC", ""),
161
+ model=data.get("model", ""),
162
+ ssid=data.get("SSID", ""),
163
+ sna_ready=data.get("SNAready", "") in ("Ready", "Yes"),
164
+ )
165
+
166
+
167
+ @dataclass
168
+ class Heater: # pylint: disable=too-many-instance-attributes
169
+ """Heater status and configuration."""
170
+
171
+ is_on: bool
172
+ heater_lock: bool
173
+ heatpump_installed: bool
174
+ heating_mode: HeatingMode
175
+ heater_current: int
176
+ heater_hours: int
177
+ set_temperature: float | None
178
+ current_temperature: float | None
179
+ temperature_unit: TemperatureUnit
180
+
181
+ @staticmethod
182
+ def from_dict(data: dict[str, object]) -> Heater:
183
+ """Create a Heater from API response data.
184
+
185
+ Args:
186
+ ----
187
+ data: The ``heater`` dict from the /status response.
188
+
189
+ Returns:
190
+ -------
191
+ A Heater instance.
192
+
193
+ """
194
+ status = data.get("status", {})
195
+ return Heater(
196
+ is_on=status.get("heater", "off") != "off",
197
+ heater_lock=status.get("heaterLock", "off") != "off",
198
+ heatpump_installed=status.get("heatpumpInstalled", "notinstalled")
199
+ != "notinstalled",
200
+ heating_mode=HeatingMode.build(status.get("heatingMode")),
201
+ heater_current=int(status.get("heaterCurrent", 0)),
202
+ heater_hours=int(status.get("heaterHours", 0)),
203
+ set_temperature=_parse_temperature(status.get("setWaterTemperature")),
204
+ current_temperature=_parse_temperature(
205
+ status.get("currentWaterTemperature")
206
+ ),
207
+ temperature_unit=TemperatureUnit.build(status.get("temperatureUnit")),
208
+ )
209
+
210
+
211
+ @dataclass
212
+ class Jet:
213
+ """Status and configuration for a single jet pump."""
214
+
215
+ jet_id: int
216
+ speed: JetSpeed
217
+ is_enabled: bool
218
+ on_seconds: int
219
+
220
+ @staticmethod
221
+ def from_dict(jet_id: int, data: dict[str, object]) -> Jet:
222
+ """Create a Jet from API response data.
223
+
224
+ Args:
225
+ ----
226
+ jet_id: The jet number (1-based).
227
+ data: The ``JETn`` dict from the /status response.
228
+
229
+ Returns:
230
+ -------
231
+ A Jet instance.
232
+
233
+ """
234
+ config = data.get("config", {})
235
+ status = data.get("status", {})
236
+
237
+ # A jet is disabled if its key in config is set to "disable"
238
+ jet_key = f"JET{jet_id}"
239
+ is_enabled = config.get(jet_key, "enable") != "disable"
240
+
241
+ # Find the on_seconds key dynamically (e.g., jet_1_ON_sec)
242
+ on_sec_key = f"jet_{jet_id}_ON_sec"
243
+ on_seconds = int(status.get(on_sec_key, 0))
244
+
245
+ return Jet(
246
+ jet_id=jet_id,
247
+ speed=JetSpeed.build(status.get("speed")),
248
+ is_enabled=is_enabled,
249
+ on_seconds=on_seconds,
250
+ )
251
+
252
+ @staticmethod
253
+ def list_from_dict(data: dict[str, object]) -> list[Jet]:
254
+ """Parse all jets from the JET section of the /status response.
255
+
256
+ Args:
257
+ ----
258
+ data: The ``JET`` dict from the /status response.
259
+
260
+ Returns:
261
+ -------
262
+ A list of Jet instances.
263
+
264
+ """
265
+ jets: list[Jet] = []
266
+ for key, value in data.items():
267
+ if key.startswith("JET") and isinstance(value, dict):
268
+ try:
269
+ jet_id = int(key[3:])
270
+ except ValueError:
271
+ continue
272
+ jets.append(Jet.from_dict(jet_id, value))
273
+ return sorted(jets, key=lambda j: j.jet_id)
274
+
275
+
276
+ @dataclass
277
+ class Blower:
278
+ """Blower status and configuration."""
279
+
280
+ is_enabled: bool
281
+ is_on: bool
282
+
283
+ @staticmethod
284
+ def from_dict(data: dict[str, object]) -> Blower:
285
+ """Create a Blower from API response data.
286
+
287
+ Args:
288
+ ----
289
+ data: The ``blower`` dict from the /status response.
290
+
291
+ Returns:
292
+ -------
293
+ A Blower instance.
294
+
295
+ """
296
+ config = data.get("config", {})
297
+ status = data.get("status", {})
298
+ return Blower(
299
+ is_enabled=config.get("blower", "disable") != "disable",
300
+ is_on=status.get("blower", "off") not in ("off", "disable"),
301
+ )
302
+
303
+
304
+ @dataclass
305
+ class LightZone:
306
+ """Status and configuration for a single light zone."""
307
+
308
+ zone_id: int
309
+ is_enabled: bool
310
+ is_on: bool
311
+ color: LightColor
312
+ light_wheel: LightWheelMode
313
+ intensity: int
314
+ loop_speed: int
315
+
316
+ @staticmethod
317
+ def from_dict(zone_id: int, data: dict[str, object]) -> LightZone:
318
+ """Create a LightZone from API response data.
319
+
320
+ Args:
321
+ ----
322
+ zone_id: The zone number (1-based).
323
+ data: The ``zoneN`` dict from the /status response.
324
+
325
+ Returns:
326
+ -------
327
+ A LightZone instance.
328
+
329
+ """
330
+ config = data.get("config", {})
331
+ status = data.get("status", {})
332
+
333
+ zone_key = f"zone_{zone_id}"
334
+ is_enabled = config.get(zone_key, "disable") != "disable"
335
+
336
+ color = LightColor.build(status.get("color"))
337
+ light_wheel = LightWheelMode.build(status.get("lightWheel"))
338
+ is_on = color not in (
339
+ LightColor.OFF,
340
+ LightColor.UNKNOWN,
341
+ ) or light_wheel not in (LightWheelMode.OFF, LightWheelMode.UNKNOWN)
342
+
343
+ return LightZone(
344
+ zone_id=zone_id,
345
+ is_enabled=is_enabled,
346
+ is_on=is_on,
347
+ color=color,
348
+ light_wheel=light_wheel,
349
+ intensity=int(status.get("Intensity", 0)),
350
+ loop_speed=int(status.get("loopSpeed", 0)),
351
+ )
352
+
353
+ @staticmethod
354
+ def list_from_dict(data: dict[str, object]) -> list[LightZone]:
355
+ """Parse all light zones from the lights section.
356
+
357
+ Args:
358
+ ----
359
+ data: The ``lights`` dict from the /status response.
360
+
361
+ Returns:
362
+ -------
363
+ A list of LightZone instances.
364
+
365
+ """
366
+ zones: list[LightZone] = []
367
+ for key, value in data.items():
368
+ if key.startswith("zone") and isinstance(value, dict):
369
+ try:
370
+ zone_id = int(key[4:])
371
+ except ValueError:
372
+ continue
373
+ zones.append(LightZone.from_dict(zone_id, value))
374
+ return sorted(zones, key=lambda z: z.zone_id)
375
+
376
+
377
+ @dataclass
378
+ class LogoLight:
379
+ """Logo light status."""
380
+
381
+ brightness: BrightnessLevel
382
+
383
+ @staticmethod
384
+ def from_dict(data: dict[str, object]) -> LogoLight:
385
+ """Create a LogoLight from API response data.
386
+
387
+ Args:
388
+ ----
389
+ data: The ``logoLight`` dict from the /status response.
390
+
391
+ Returns:
392
+ -------
393
+ A LogoLight instance.
394
+
395
+ """
396
+ status = data.get("status", {})
397
+ return LogoLight(
398
+ brightness=BrightnessLevel.build(status.get("brightness")),
399
+ )
400
+
401
+
402
+ @dataclass
403
+ class CleanCycle:
404
+ """Clean cycle status and configuration."""
405
+
406
+ is_enabled: bool
407
+ vanishing_act: bool
408
+
409
+ @staticmethod
410
+ def from_dict(data: dict[str, object]) -> CleanCycle:
411
+ """Create a CleanCycle from API response data.
412
+
413
+ Args:
414
+ ----
415
+ data: The ``cleanCycle`` dict from the /status response.
416
+
417
+ Returns:
418
+ -------
419
+ A CleanCycle instance.
420
+
421
+ """
422
+ status = data.get("status", {})
423
+ return CleanCycle(
424
+ is_enabled=status.get("cleanCycle", "disable") == "enable",
425
+ vanishing_act=status.get("vanishingAct", "off") != "off",
426
+ )
427
+
428
+
429
+ @dataclass
430
+ class SpaLock:
431
+ """Spa lock status."""
432
+
433
+ is_locked: bool
434
+
435
+ @staticmethod
436
+ def from_dict(data: dict[str, object]) -> SpaLock:
437
+ """Create a SpaLock from API response data.
438
+
439
+ Args:
440
+ ----
441
+ data: The ``spaLock`` dict from the /status response.
442
+
443
+ Returns:
444
+ -------
445
+ A SpaLock instance.
446
+
447
+ """
448
+ status = data.get("status", {})
449
+ return SpaLock(
450
+ is_locked=status.get("spaLock", "off") != "off",
451
+ )
452
+
453
+
454
+ @dataclass
455
+ class WaterCare: # pylint: disable=too-many-instance-attributes
456
+ """Water care / salt system status."""
457
+
458
+ cartridge_installed: bool
459
+ ten_day_timer: int
460
+ one_twenty_day_timer: int
461
+ level: int
462
+ system_enabled: bool
463
+ ace_mode: str
464
+ boost_active: bool
465
+ salt_level: str
466
+ salt_value: int
467
+
468
+ @staticmethod
469
+ def from_dict(data: dict[str, object]) -> WaterCare:
470
+ """Create a WaterCare from API response data.
471
+
472
+ Args:
473
+ ----
474
+ data: The ``waterCare`` dict from the /status response.
475
+
476
+ Returns:
477
+ -------
478
+ A WaterCare instance.
479
+
480
+ """
481
+ status = data.get("status", {})
482
+ return WaterCare(
483
+ cartridge_installed=status.get("cartridgeInstalled", "notinstalled")
484
+ != "notinstalled",
485
+ ten_day_timer=int(status.get("10DayTimer", 0)),
486
+ one_twenty_day_timer=int(status.get("120DayTimer", 0)),
487
+ level=int(status.get("level", 0)),
488
+ system_enabled=status.get("SystemEnable", "disable") == "enable",
489
+ ace_mode=status.get("AceMode", "inactive"),
490
+ boost_active=status.get("boost", "inactive") != "inactive",
491
+ salt_level=status.get("saltLevel", ""),
492
+ salt_value=int(status.get("saltValue", 0)),
493
+ )
494
+
495
+
496
+ @dataclass
497
+ class FreshWaterIQ:
498
+ """FreshWater IQ water quality sensor data."""
499
+
500
+ conductivity: int
501
+ orp: int
502
+ chlorine: float
503
+ ph: float
504
+ sensor_life_percentage: float
505
+
506
+ installed: bool
507
+
508
+ @staticmethod
509
+ def from_dict(data: dict[str, object]) -> FreshWaterIQ:
510
+ """Create a FreshWaterIQ from API response data.
511
+
512
+ Handles two response formats:
513
+ - ``FWIQ_Parameters`` from /status (flat keys)
514
+ - ``waterCare.status.FWIQstatus`` from /getFWIQData (nested)
515
+
516
+ Args:
517
+ ----
518
+ data: Data from either source.
519
+
520
+ Returns:
521
+ -------
522
+ A FreshWaterIQ instance.
523
+
524
+ """
525
+ # Handle the nested /getFWIQData format
526
+ fwiq = data.get("waterCare", {}).get("status", {}).get("FWIQstatus", {})
527
+ if fwiq:
528
+ return FreshWaterIQ(
529
+ conductivity=int(fwiq.get("Conductivity", 0)),
530
+ orp=int(fwiq.get("ORP", 0)),
531
+ chlorine=float(fwiq.get("Chlorine", 0.0)),
532
+ ph=float(fwiq.get("pH", 0.0)),
533
+ sensor_life_percentage=float(fwiq.get("SensorLife", 0.0)),
534
+ installed=fwiq.get("FWIQinstalled", "notinstalled") != "notinstalled",
535
+ )
536
+
537
+ # Handle the flat /status FWIQ_Parameters format
538
+ return FreshWaterIQ(
539
+ conductivity=int(data.get("current_Current_CompConductivity", 0)),
540
+ orp=int(data.get("current_ORP", 0)),
541
+ chlorine=float(data.get("current_chlorine", 0.0)),
542
+ ph=float(data.get("current_pH", 0.0)),
543
+ sensor_life_percentage=float(
544
+ data.get("current_SensorLife_Percentage", 0.0)
545
+ ),
546
+ installed=True, # Assume installed if using flat format
547
+ )
548
+
549
+
550
+ @dataclass
551
+ class EnergySaving:
552
+ """Energy saving schedule configuration."""
553
+
554
+ schedule_id: int
555
+ mode: int
556
+ start_hour: int
557
+ start_minute: int
558
+ duration: int
559
+
560
+ @staticmethod
561
+ def from_dict(schedule_id: int, data: dict[str, object]) -> EnergySaving:
562
+ """Create an EnergySaving from API response data.
563
+
564
+ Args:
565
+ ----
566
+ schedule_id: The schedule number (1-based).
567
+ data: The ``energySavingN`` dict from the /status response.
568
+
569
+ Returns:
570
+ -------
571
+ An EnergySaving instance.
572
+
573
+ """
574
+ status = data.get("status", {})
575
+ return EnergySaving(
576
+ schedule_id=schedule_id,
577
+ mode=int(status.get("mode", 0)),
578
+ start_hour=int(status.get("startHour", 0)),
579
+ start_minute=int(status.get("startMinute", 0)),
580
+ duration=int(status.get("duration", 0)),
581
+ )
582
+
583
+ @staticmethod
584
+ def list_from_dict(data: dict[str, object]) -> list[EnergySaving]:
585
+ """Parse all energy saving schedules from the /status response.
586
+
587
+ Args:
588
+ ----
589
+ data: The ``energySavings`` dict from the /status response.
590
+
591
+ Returns:
592
+ -------
593
+ A list of EnergySaving instances.
594
+
595
+ """
596
+ schedules: list[EnergySaving] = []
597
+ for key, value in data.items():
598
+ if key.startswith("energySaving") and isinstance(value, dict):
599
+ try:
600
+ schedule_id = int(key[12:])
601
+ except ValueError:
602
+ continue
603
+ schedules.append(EnergySaving.from_dict(schedule_id, value))
604
+ return sorted(schedules, key=lambda s: s.schedule_id)
605
+
606
+
607
+ @dataclass
608
+ class Versions: # pylint: disable=too-many-instance-attributes
609
+ """Firmware versions for all spa sub-components."""
610
+
611
+ control_box: str
612
+ control_panel: str
613
+ fwss: str
614
+ fwiq: str
615
+ btxr: str
616
+ cool_zone: str
617
+ wifi_dongle: str
618
+ amp: str
619
+ dosing: str
620
+ logolight: str
621
+
622
+ @staticmethod
623
+ def from_dict(data: dict[str, object]) -> Versions:
624
+ """Create a Versions from API response data.
625
+
626
+ Args:
627
+ ----
628
+ data: The ``productVersions.status`` dict from /status
629
+ or from GET /versions.
630
+
631
+ Returns:
632
+ -------
633
+ A Versions instance.
634
+
635
+ """
636
+ return Versions(
637
+ control_box=data.get("ControlBoxFirmwareVersion", ""),
638
+ control_panel=data.get("ControlPanelFirmwareVersion", ""),
639
+ fwss=data.get("FWSSFirmwareVersion", ""),
640
+ fwiq=data.get("FWIQFirmwareVersion", ""),
641
+ btxr=data.get("BTXRFirmwareVersion", ""),
642
+ cool_zone=data.get("CoolZoneFirmwareVersion", ""),
643
+ wifi_dongle=data.get("WiFiDongleVersion", ""),
644
+ amp=data.get("AMPFirmwareVersion", ""),
645
+ dosing=data.get("DosingFirmwareVersion", ""),
646
+ logolight=data.get("LogolightFirmwareVersion", ""),
647
+ )
648
+
649
+
650
+ @dataclass
651
+ class ConnectionStatus:
652
+ """Connection status between the HNA, SNA, and cloud."""
653
+
654
+ spa_connected: bool
655
+
656
+ @staticmethod
657
+ def from_dict(data: dict[str, object]) -> ConnectionStatus:
658
+ """Create a ConnectionStatus from API response data.
659
+
660
+ The real API returns ``{"spaConnectStatus": "true"}`` as a single
661
+ field, not separate cloud/sna booleans.
662
+
663
+ Args:
664
+ ----
665
+ data: The JSON response from GET /spaConnectStatus.
666
+
667
+ Returns:
668
+ -------
669
+ A ConnectionStatus instance.
670
+
671
+ """
672
+ raw = data.get("spaConnectStatus", "false")
673
+ connected = str(raw).lower() in ("true", "1")
674
+ return ConnectionStatus(
675
+ spa_connected=connected,
676
+ )
677
+
678
+
679
+ @dataclass
680
+ class Diagnostics: # pylint: disable=too-many-instance-attributes
681
+ """Diagnostic and power metrics from the spa.
682
+
683
+ Availability depends on the spa model and whether the main control
684
+ board (IQ2020/Eagle) is equipped with current-sensing transformers.
685
+ Values may be ``0`` if sensors are not installed.
686
+ """
687
+
688
+ spa_failure_state: SpaFailureState
689
+ heater_error: str
690
+ power_frequency: str
691
+ pressure_switch_status: str
692
+ l1_n_volts: str
693
+ l2_n_volts: str
694
+ heater_volts: str
695
+ jet3_volts: str
696
+ jet1_jet2_blower_power: str
697
+ small_loads_power: str
698
+ heater_power: str
699
+ jet3_power: str
700
+
701
+ @staticmethod
702
+ def from_dict(data: dict[str, object]) -> Diagnostics:
703
+ """Create a Diagnostics from API response data.
704
+
705
+ Args:
706
+ ----
707
+ data: The JSON response from GET /addDebugData, or an empty
708
+ dict for default values.
709
+
710
+ Returns:
711
+ -------
712
+ A Diagnostics instance.
713
+
714
+ """
715
+ debug = data.get("debugData", {}).get("status", {})
716
+ return Diagnostics(
717
+ spa_failure_state=SpaFailureState.build(debug.get("spaFailureState")),
718
+ heater_error=debug.get("heaterError", "0"),
719
+ power_frequency=debug.get("powerFrequency", "0"),
720
+ pressure_switch_status=debug.get("pressureSwitchStatus", "0"),
721
+ l1_n_volts=debug.get("L1_N_Volts", "0"),
722
+ l2_n_volts=debug.get("L2_N_Volts", "0"),
723
+ heater_volts=debug.get("Heater_Volts", "0"),
724
+ jet3_volts=debug.get("jet3_Volts", "0"),
725
+ jet1_jet2_blower_power=debug.get("jet1_jet2_blowerPower", "0"),
726
+ small_loads_power=debug.get("smallLoadsPower", "0"),
727
+ heater_power=debug.get("heaterPower", "0"),
728
+ jet3_power=debug.get("jet3Power", "0"),
729
+ )
730
+
731
+
732
+ def _parse_temperature(value: str | float | None) -> float | None:
733
+ """Parse a temperature value from the API.
734
+
735
+ The API returns temperatures in formats like " 97F", " 38C",
736
+ "100", or empty strings. This function strips whitespace and
737
+ unit suffixes before parsing.
738
+
739
+ Args:
740
+ ----
741
+ value: The raw temperature value from the API.
742
+
743
+ Returns:
744
+ -------
745
+ The temperature as a float, or None if not available.
746
+
747
+ """
748
+ if value is None:
749
+ return None
750
+ # Convert to string and strip whitespace
751
+ text = str(value).strip()
752
+ if not text:
753
+ return None
754
+ # Strip trailing unit suffix (F or C)
755
+ if text[-1] in ("F", "C"):
756
+ text = text[:-1].strip()
757
+ if not text:
758
+ return None
759
+ try:
760
+ return float(text)
761
+ except (ValueError, TypeError):
762
+ return None