plugwise 1.5.1a3__py3-none-any.whl → 1.6.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.
plugwise/helper.py CHANGED
@@ -24,6 +24,7 @@ from plugwise.constants import (
24
24
  LIMITS,
25
25
  LOCATIONS,
26
26
  LOGGER,
27
+ MODULE_LOCATOR,
27
28
  NONE,
28
29
  OFF,
29
30
  P1_MEASUREMENTS,
@@ -31,11 +32,12 @@ from plugwise.constants import (
31
32
  THERMOSTAT_CLASSES,
32
33
  TOGGLES,
33
34
  UOM,
35
+ ZONE_MEASUREMENTS,
34
36
  ActuatorData,
35
37
  ActuatorDataType,
36
38
  ActuatorType,
37
- DeviceData,
38
39
  GatewayData,
40
+ GwEntityData,
39
41
  SensorType,
40
42
  ThermoLoc,
41
43
  ToggleNameType,
@@ -222,6 +224,7 @@ class SmileHelper(SmileCommon):
222
224
  self._is_thermostat: bool
223
225
  self._last_active: dict[str, str | None]
224
226
  self._last_modified: dict[str, str] = {}
227
+ self._loc_data: dict[str, ThermoLoc]
225
228
  self._notifications: dict[str, dict[str, str]] = {}
226
229
  self._on_off_device: bool
227
230
  self._opentherm_device: bool
@@ -248,8 +251,7 @@ class SmileHelper(SmileCommon):
248
251
 
249
252
  self.gateway_id: str
250
253
  self.gw_data: GatewayData = {}
251
- self.gw_devices: dict[str, DeviceData] = {}
252
- self.loc_data: dict[str, ThermoLoc]
254
+ self.gw_entities: dict[str, GwEntityData] = {}
253
255
  self.smile_fw_version: Version | None
254
256
  self.smile_hw_version: str | None
255
257
  self.smile_mac_address: str | None
@@ -259,6 +261,7 @@ class SmileHelper(SmileCommon):
259
261
  self.smile_type: str
260
262
  self.smile_zigbee_mac_address: str | None
261
263
  self.therms_with_offset_func: list[str] = []
264
+ self._zones: dict[str, GwEntityData] = {}
262
265
  SmileCommon.__init__(self)
263
266
 
264
267
  def _all_appliances(self) -> None:
@@ -299,7 +302,8 @@ class SmileHelper(SmileCommon):
299
302
  if appl.pwclass in THERMOSTAT_CLASSES and appl.location is None:
300
303
  continue
301
304
 
302
- appl.dev_id = appliance.attrib["id"]
305
+ appl.available = None
306
+ appl.entity_id = appliance.attrib["id"]
303
307
  appl.name = appliance.find("name").text
304
308
  appl.model = None
305
309
  appl.model_id = None
@@ -313,18 +317,18 @@ class SmileHelper(SmileCommon):
313
317
  if not (appl := self._appliance_info_finder(appl, appliance)):
314
318
  continue
315
319
 
316
- # P1: for gateway and smartmeter switch device_id - part 1
320
+ # P1: for gateway and smartmeter switch entity_id - part 1
317
321
  # This is done to avoid breakage in HA Core
318
322
  if appl.pwclass == "gateway" and self.smile_type == "power":
319
- appl.dev_id = appl.location
323
+ appl.entity_id = appl.location
320
324
 
321
- self._create_gw_devices(appl)
325
+ self._create_gw_entities(appl)
322
326
 
323
327
  # For P1 collect the connected SmartMeter info
324
328
  if self.smile_type == "power":
325
329
  self._p1_smartmeter_info_finder(appl)
326
- # P1: for gateway and smartmeter switch device_id - part 2
327
- for item in self.gw_devices:
330
+ # P1: for gateway and smartmeter switch entity_id - part 2
331
+ for item in self.gw_entities:
328
332
  if item != self.gateway_id:
329
333
  self.gateway_id = item
330
334
  # Leave for-loop to avoid a 2nd device_id switch
@@ -332,13 +336,13 @@ class SmileHelper(SmileCommon):
332
336
 
333
337
  # Place the gateway and optional heater_central devices as 1st and 2nd
334
338
  for dev_class in ("heater_central", "gateway"):
335
- for dev_id, device in dict(self.gw_devices).items():
336
- if device["dev_class"] == dev_class:
337
- tmp_device = device
338
- self.gw_devices.pop(dev_id)
339
- cleared_dict = self.gw_devices
340
- add_to_front = {dev_id: tmp_device}
341
- self.gw_devices = {**add_to_front, **cleared_dict}
339
+ for entity_id, entity in dict(self.gw_entities).items():
340
+ if entity["dev_class"] == dev_class:
341
+ tmp_entity = entity
342
+ self.gw_entities.pop(entity_id)
343
+ cleared_dict = self.gw_entities
344
+ add_to_front = {entity_id: tmp_entity}
345
+ self.gw_entities = {**add_to_front, **cleared_dict}
342
346
 
343
347
  def _all_locations(self) -> None:
344
348
  """Collect all locations."""
@@ -350,20 +354,19 @@ class SmileHelper(SmileCommon):
350
354
  if loc.name == "Home":
351
355
  self._home_location = loc.loc_id
352
356
 
353
- self.loc_data[loc.loc_id] = {"name": loc.name}
357
+ self._loc_data[loc.loc_id] = {"name": loc.name}
354
358
 
355
359
  def _p1_smartmeter_info_finder(self, appl: Munch) -> None:
356
360
  """Collect P1 DSMR SmartMeter info."""
357
- loc_id = next(iter(self.loc_data.keys()))
361
+ loc_id = next(iter(self._loc_data.keys()))
358
362
  location = self._domain_objects.find(f'./location[@id="{loc_id}"]')
359
- locator = "./logs/point_log/electricity_point_meter"
360
- mod_type = "electricity_point_meter"
361
- module_data = self._get_module_data(location, locator, mod_type)
363
+ locator = MODULE_LOCATOR
364
+ module_data = self._get_module_data(location, locator)
362
365
  if not module_data["contents"]:
363
366
  LOGGER.error("No module data found for SmartMeter") # pragma: no cover
364
367
  return None # pragma: no cover
365
368
 
366
- appl.dev_id = self.gateway_id
369
+ appl.entity_id = self.gateway_id
367
370
  appl.firmware = module_data["firmware_version"]
368
371
  appl.hardware = module_data["hardware_version"]
369
372
  appl.location = loc_id
@@ -375,7 +378,7 @@ class SmileHelper(SmileCommon):
375
378
  appl.vendor_name = module_data["vendor_name"]
376
379
  appl.zigbee_mac = None
377
380
 
378
- self._create_gw_devices(appl)
381
+ self._create_gw_entities(appl)
379
382
 
380
383
  def _appliance_info_finder(self, appl: Munch, appliance: etree) -> Munch:
381
384
  """Collect info for all appliances found."""
@@ -391,18 +394,18 @@ class SmileHelper(SmileCommon):
391
394
  self._appl_heater_central_info(appl, appliance, False) # False means non-legacy device
392
395
  self._appl_dhw_mode_info(appl, appliance)
393
396
  # Skip orphaned heater_central (Core Issue #104433)
394
- if appl.dev_id != self._heater_id:
397
+ if appl.entity_id != self._heater_id:
395
398
  return Munch()
396
399
  return appl
397
400
  case _ as s if s.endswith("_plug"):
398
401
  # Collect info from plug-types (Plug, Aqara Smart Plug)
399
- locator = "./logs/interval_log/electricity_interval_meter"
400
- mod_type = "electricity_interval_meter"
401
- module_data = self._get_module_data(appliance, locator, mod_type)
402
+ locator = MODULE_LOCATOR
403
+ module_data = self._get_module_data(appliance, locator)
402
404
  # A plug without module-data is orphaned/ no present
403
405
  if not module_data["contents"]:
404
406
  return Munch()
405
407
 
408
+ appl.available = module_data["reachable"]
406
409
  appl.firmware = module_data["firmware_version"]
407
410
  appl.hardware = module_data["hardware_version"]
408
411
  appl.model_id = module_data["vendor_model"]
@@ -479,24 +482,40 @@ class SmileHelper(SmileCommon):
479
482
 
480
483
  return therm_list
481
484
 
482
- def _get_measurement_data(self, dev_id: str) -> DeviceData:
483
- """Helper-function for smile.py: _get_device_data().
485
+ def _get_zone_data(self, loc_id: str) -> GwEntityData:
486
+ """Helper-function for smile.py: _get_entity_data().
484
487
 
485
- Collect the appliance-data based on device id.
488
+ Collect the location-data based on location id.
486
489
  """
487
- data: DeviceData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
490
+ data: GwEntityData = {"sensors": {}}
491
+ zone = self._zones[loc_id]
492
+ measurements = ZONE_MEASUREMENTS
493
+ if (
494
+ location := self._domain_objects.find(f'./location[@id="{loc_id}"]')
495
+ ) is not None:
496
+ self._appliance_measurements(location, data, measurements)
497
+ self._get_actuator_functionalities(location, zone, data)
498
+
499
+ return data
500
+
501
+ def _get_measurement_data(self, entity_id: str) -> GwEntityData:
502
+ """Helper-function for smile.py: _get_entity_data().
503
+
504
+ Collect the appliance-data based on entity_id.
505
+ """
506
+ data: GwEntityData = {"binary_sensors": {}, "sensors": {}, "switches": {}}
488
507
  # Get P1 smartmeter data from LOCATIONS
489
- device = self.gw_devices[dev_id]
508
+ entity = self.gw_entities[entity_id]
490
509
  # !! DON'T CHANGE below two if-lines, will break stuff !!
491
510
  if self.smile_type == "power":
492
- if device["dev_class"] == "smartmeter":
493
- data.update(self._power_data_from_location(device["location"]))
511
+ if entity["dev_class"] == "smartmeter":
512
+ data.update(self._power_data_from_location(entity["location"]))
494
513
 
495
514
  return data
496
515
 
497
516
  # Get non-P1 data from APPLIANCES
498
517
  measurements = DEVICE_MEASUREMENTS
499
- if self._is_thermostat and dev_id == self._heater_id:
518
+ if self._is_thermostat and entity_id == self._heater_id:
500
519
  measurements = HEATER_CENTRAL_MEASUREMENTS
501
520
  # Show the allowed dhw_modes (Loria only)
502
521
  if self._dhw_allowed_modes:
@@ -504,7 +523,7 @@ class SmileHelper(SmileCommon):
504
523
  # Counting of this item is done in _appliance_measurements()
505
524
 
506
525
  if (
507
- appliance := self._domain_objects.find(f'./appliance[@id="{dev_id}"]')
526
+ appliance := self._domain_objects.find(f'./appliance[@id="{entity_id}"]')
508
527
  ) is not None:
509
528
  self._appliance_measurements(appliance, data, measurements)
510
529
  self._get_lock_state(appliance, data)
@@ -513,24 +532,11 @@ class SmileHelper(SmileCommon):
513
532
  self._get_toggle_state(appliance, toggle, name, data)
514
533
 
515
534
  if appliance.find("type").text in ACTUATOR_CLASSES:
516
- self._get_actuator_functionalities(appliance, device, data)
535
+ self._get_actuator_functionalities(appliance, entity, data)
517
536
 
518
- # Collect availability-status for wireless connected devices to Adam
519
- self._wireless_availability(appliance, data)
520
-
521
- if dev_id == self.gateway_id and self.smile(ADAM):
522
- self._get_regulation_mode(appliance, data)
523
- self._get_gateway_mode(appliance, data)
524
-
525
- # Adam & Anna: the Smile outdoor_temperature is present in DOMAIN_OBJECTS and LOCATIONS - under Home
526
- # The outdoor_temperature present in APPLIANCES is a local sensor connected to the active device
527
- if self._is_thermostat and dev_id == self.gateway_id:
528
- outdoor_temperature = self._object_value(
529
- self._home_location, "outdoor_temperature"
530
- )
531
- if outdoor_temperature is not None:
532
- data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
533
- self._count += 1
537
+ self._get_regulation_mode(appliance, entity_id, data)
538
+ self._get_gateway_mode(appliance, entity_id, data)
539
+ self._get_gateway_outdoor_temp(entity_id, data)
534
540
 
535
541
  if "c_heating_state" in data:
536
542
  self._process_c_heating_state(data)
@@ -538,45 +544,19 @@ class SmileHelper(SmileCommon):
538
544
  data.pop("c_heating_state")
539
545
  self._count -= 1
540
546
 
541
- if self._is_thermostat and self.smile(ANNA) and dev_id == self._heater_id:
542
- # Anna+Elga: base cooling_state on the elga-status-code
543
- if "elga_status_code" in data:
544
- if data["thermostat_supports_cooling"]:
545
- # Techneco Elga has cooling-capability
546
- self._cooling_present = True
547
- data["model"] = "Generic heater/cooler"
548
- self._cooling_enabled = data["elga_status_code"] in (8, 9)
549
- data["binary_sensors"]["cooling_state"] = self._cooling_active = (
550
- data["elga_status_code"] == 8
551
- )
552
- # Elga has no cooling-switch
553
- if "cooling_ena_switch" in data["switches"]:
554
- data["switches"].pop("cooling_ena_switch")
555
- self._count -= 1
556
-
557
- data.pop("elga_status_code", None)
558
- self._count -= 1
559
-
560
- # Loria/Thermastage: cooling-related is based on cooling_state
561
- # and modulation_level
562
- elif self._cooling_present and "cooling_state" in data["binary_sensors"]:
563
- self._cooling_enabled = data["binary_sensors"]["cooling_state"]
564
- self._cooling_active = data["sensors"]["modulation_level"] == 100
565
- # For Loria the above does not work (pw-beta issue #301)
566
- if "cooling_ena_switch" in data["switches"]:
567
- self._cooling_enabled = data["switches"]["cooling_ena_switch"]
568
- self._cooling_active = data["binary_sensors"]["cooling_state"]
547
+ if self._is_thermostat and self.smile(ANNA):
548
+ self._update_anna_cooling(entity_id, data)
569
549
 
570
550
  self._cleanup_data(data)
571
551
 
572
552
  return data
573
553
 
574
- def _power_data_from_location(self, loc_id: str) -> DeviceData:
575
- """Helper-function for smile.py: _get_device_data().
554
+ def _power_data_from_location(self, loc_id: str) -> GwEntityData:
555
+ """Helper-function for smile.py: _get_entity_data().
576
556
 
577
557
  Collect the power-data based on Location ID, from LOCATIONS.
578
558
  """
579
- direct_data: DeviceData = {"sensors": {}}
559
+ data: GwEntityData = {"sensors": {}}
580
560
  loc = Munch()
581
561
  log_list: list[str] = ["point_log", "cumulative_log", "interval_log"]
582
562
  t_string = "tariff"
@@ -585,15 +565,15 @@ class SmileHelper(SmileCommon):
585
565
  loc.logs = search.find(f'./location[@id="{loc_id}"]/logs')
586
566
  for loc.measurement, loc.attrs in P1_MEASUREMENTS.items():
587
567
  for loc.log_type in log_list:
588
- self._collect_power_values(direct_data, loc, t_string)
568
+ self._collect_power_values(data, loc, t_string)
589
569
 
590
- self._count += len(direct_data["sensors"])
591
- return direct_data
570
+ self._count += len(data["sensors"])
571
+ return data
592
572
 
593
573
  def _appliance_measurements(
594
574
  self,
595
575
  appliance: etree,
596
- data: DeviceData,
576
+ data: GwEntityData,
597
577
  measurements: dict[str, DATA | UOM],
598
578
  ) -> None:
599
579
  """Helper-function for _get_measurement_data() - collect appliance measurement data."""
@@ -621,14 +601,10 @@ class SmileHelper(SmileCommon):
621
601
  appl_i_loc.text, ENERGY_WATT_HOUR
622
602
  )
623
603
 
624
- self._count += len(data["binary_sensors"])
625
- self._count += len(data["sensors"])
626
- self._count += len(data["switches"])
627
- # Don't count the above top-level dicts, only the remaining single items
628
- self._count += len(data) - 3
604
+ self._count_data_items(data)
629
605
 
630
606
  def _get_toggle_state(
631
- self, xml: etree, toggle: str, name: ToggleNameType, data: DeviceData
607
+ self, xml: etree, toggle: str, name: ToggleNameType, data: GwEntityData
632
608
  ) -> None:
633
609
  """Helper-function for _get_measurement_data().
634
610
 
@@ -657,14 +633,16 @@ class SmileHelper(SmileCommon):
657
633
  )
658
634
 
659
635
  def _get_actuator_functionalities(
660
- self, xml: etree, device: DeviceData, data: DeviceData
636
+ self, xml: etree, entity: GwEntityData, data: GwEntityData
661
637
  ) -> None:
662
638
  """Helper-function for _get_measurement_data()."""
663
639
  for item in ACTIVE_ACTUATORS:
664
640
  # Skip max_dhw_temperature, not initially valid,
665
- # skip thermostat for thermo_sensors
641
+ # skip thermostat for all but zones with thermostats
666
642
  if item == "max_dhw_temperature" or (
667
- item == "thermostat" and device["dev_class"] == "thermo_sensor"
643
+ item == "thermostat" and (
644
+ entity["dev_class"] != "climate" if self.smile(ADAM) else entity["dev_class"] != "thermostat"
645
+ )
668
646
  ):
669
647
  continue
670
648
 
@@ -710,52 +688,52 @@ class SmileHelper(SmileCommon):
710
688
  act_item = cast(ActuatorType, item)
711
689
  data[act_item] = temp_dict
712
690
 
713
- def _wireless_availability(self, appliance: etree, data: DeviceData) -> None:
691
+ def _get_regulation_mode(
692
+ self, appliance: etree, entity_id: str, data: GwEntityData
693
+ ) -> None:
714
694
  """Helper-function for _get_measurement_data().
715
695
 
716
- Collect the availability-status for wireless connected devices.
696
+ Adam: collect the gateway regulation_mode.
717
697
  """
718
- if self.smile(ADAM):
719
- # Try collecting for a Plug
720
- locator = "./logs/interval_log/electricity_interval_meter"
721
- mod_type = "electricity_interval_meter"
722
- module_data = self._get_module_data(appliance, locator, mod_type)
723
- if not module_data["contents"]:
724
- # Try collecting for a wireless thermostat
725
- locator = "./logs/point_log[type='thermostat']/thermostat"
726
- mod_type = "thermostat"
727
- module_data = self._get_module_data(appliance, locator, mod_type)
728
- if not module_data["contents"]:
729
- LOGGER.error("No module data found for Plug or wireless thermostat") # pragma: no cover
730
- return None # pragma: no cover
731
-
732
- if module_data["reachable"] is not None:
733
- data["available"] = module_data["reachable"]
734
- self._count += 1
735
-
736
- def _get_regulation_mode(self, appliance: etree, data: DeviceData) -> None:
737
- """Helper-function for _get_measurement_data().
698
+ if not (self.smile(ADAM) and entity_id == self.gateway_id):
699
+ return
738
700
 
739
- Collect the gateway regulation_mode.
740
- """
741
701
  locator = "./actuator_functionalities/regulation_mode_control_functionality"
742
702
  if (search := appliance.find(locator)) is not None:
743
703
  data["select_regulation_mode"] = search.find("mode").text
744
704
  self._count += 1
745
705
  self._cooling_enabled = data["select_regulation_mode"] == "cooling"
746
706
 
747
- def _get_gateway_mode(self, appliance: etree, data: DeviceData) -> None:
707
+ def _get_gateway_mode(
708
+ self, appliance: etree, entity_id: str, data: GwEntityData
709
+ ) -> None:
748
710
  """Helper-function for _get_measurement_data().
749
711
 
750
- Collect the gateway mode.
712
+ Adam: collect the gateway mode.
751
713
  """
714
+ if not (self.smile(ADAM) and entity_id == self.gateway_id):
715
+ return
716
+
752
717
  locator = "./actuator_functionalities/gateway_mode_control_functionality"
753
718
  if (search := appliance.find(locator)) is not None:
754
719
  data["select_gateway_mode"] = search.find("mode").text
755
720
  self._count += 1
756
721
 
722
+ def _get_gateway_outdoor_temp(self, entity_id: str, data: GwEntityData) -> None:
723
+ """Adam & Anna: the Smile outdoor_temperature is present in DOMAIN_OBJECTS and LOCATIONS.
724
+
725
+ Available under the Home location.
726
+ """
727
+ if self._is_thermostat and entity_id == self.gateway_id:
728
+ outdoor_temperature = self._object_value(
729
+ self._home_location, "outdoor_temperature"
730
+ )
731
+ if outdoor_temperature is not None:
732
+ data.update({"sensors": {"outdoor_temperature": outdoor_temperature}})
733
+ self._count += 1
734
+
757
735
  def _object_value(self, obj_id: str, measurement: str) -> float | int | None:
758
- """Helper-function for smile.py: _get_device_data() and _device_data_anna().
736
+ """Helper-function for smile.py: _get_entity_data().
759
737
 
760
738
  Obtain the value/state for the given object from a location in DOMAIN_OBJECTS
761
739
  """
@@ -767,35 +745,80 @@ class SmileHelper(SmileCommon):
767
745
 
768
746
  return val
769
747
 
770
- def _process_c_heating_state(self, data: DeviceData) -> None:
748
+ def _process_c_heating_state(self, data: GwEntityData) -> None:
771
749
  """Helper-function for _get_measurement_data().
772
750
 
773
751
  Process the central_heating_state value.
774
752
  """
753
+ # Adam or Anna + OnOff device
775
754
  if self._on_off_device:
776
- # Anna + OnOff heater: use central_heating_state to show heating_state
777
- # Solution for Core issue #81839
778
- if self.smile(ANNA):
779
- data["binary_sensors"]["heating_state"] = data["c_heating_state"]
780
-
781
- # Adam + OnOff cooling: use central_heating_state to show heating/cooling_state
782
- if self.smile(ADAM):
783
- if "heating_state" not in data["binary_sensors"]:
784
- self._count += 1
785
- data["binary_sensors"]["heating_state"] = False
786
- if "cooling_state" not in data["binary_sensors"]:
787
- self._count += 1
788
- data["binary_sensors"]["cooling_state"] = False
789
- if self._cooling_enabled:
790
- data["binary_sensors"]["cooling_state"] = data["c_heating_state"]
791
- else:
792
- data["binary_sensors"]["heating_state"] = data["c_heating_state"]
755
+ self._process_on_off_device_c_heating_state(data)
793
756
 
794
757
  # Anna + Elga: use central_heating_state to show heating_state
795
758
  if self._elga:
796
759
  data["binary_sensors"]["heating_state"] = data["c_heating_state"]
797
760
 
798
- def _cleanup_data(self, data: DeviceData) -> None:
761
+ def _process_on_off_device_c_heating_state(self, data: GwEntityData) -> None:
762
+ """Adam or Anna + OnOff device - use central_heating_state to show heating/cooling_state.
763
+
764
+ Solution for Core issue #81839.
765
+ """
766
+ if self.smile(ANNA):
767
+ data["binary_sensors"]["heating_state"] = data["c_heating_state"]
768
+
769
+ if self.smile(ADAM):
770
+ if "heating_state" not in data["binary_sensors"]:
771
+ self._count += 1
772
+ data["binary_sensors"]["heating_state"] = False
773
+ if "cooling_state" not in data["binary_sensors"]:
774
+ self._count += 1
775
+ data["binary_sensors"]["cooling_state"] = False
776
+ if self._cooling_enabled:
777
+ data["binary_sensors"]["cooling_state"] = data["c_heating_state"]
778
+ else:
779
+ data["binary_sensors"]["heating_state"] = data["c_heating_state"]
780
+
781
+ def _update_anna_cooling(self, entity_id: str, data: GwEntityData) -> None:
782
+ """Update the Anna heater_central device for cooling.
783
+
784
+ Support added for Techneco Elga and Thercon Loria/Thermastage.
785
+ """
786
+ if entity_id != self._heater_id:
787
+ return
788
+
789
+ if "elga_status_code" in data:
790
+ self._update_elga_cooling(data)
791
+ elif self._cooling_present and "cooling_state" in data["binary_sensors"]:
792
+ self._update_loria_cooling(data)
793
+
794
+ def _update_elga_cooling(self, data: GwEntityData) -> None:
795
+ """# Anna+Elga: base cooling_state on the elga-status-code."""
796
+ if data["thermostat_supports_cooling"]:
797
+ # Techneco Elga has cooling-capability
798
+ self._cooling_present = True
799
+ data["model"] = "Generic heater/cooler"
800
+ self._cooling_enabled = data["elga_status_code"] in (8, 9)
801
+ data["binary_sensors"]["cooling_state"] = self._cooling_active = (
802
+ data["elga_status_code"] == 8
803
+ )
804
+ # Elga has no cooling-switch
805
+ if "cooling_ena_switch" in data["switches"]:
806
+ data["switches"].pop("cooling_ena_switch")
807
+ self._count -= 1
808
+
809
+ data.pop("elga_status_code", None)
810
+ self._count -= 1
811
+
812
+ def _update_loria_cooling(self, data: GwEntityData) -> None:
813
+ """Loria/Thermastage: base cooling-related on cooling_state and modulation_level."""
814
+ self._cooling_enabled = data["binary_sensors"]["cooling_state"]
815
+ self._cooling_active = data["sensors"]["modulation_level"] == 100
816
+ # For Loria the above does not work (pw-beta issue #301)
817
+ if "cooling_ena_switch" in data["switches"]:
818
+ self._cooling_enabled = data["switches"]["cooling_ena_switch"]
819
+ self._cooling_active = data["binary_sensors"]["cooling_state"]
820
+
821
+ def _cleanup_data(self, data: GwEntityData) -> None:
799
822
  """Helper-function for _get_measurement_data().
800
823
 
801
824
  Clean up the data dict.
@@ -818,7 +841,7 @@ class SmileHelper(SmileCommon):
818
841
  self._count -= 1
819
842
 
820
843
  def _scan_thermostats(self) -> None:
821
- """Helper-function for smile.py: get_all_devices().
844
+ """Helper-function for smile.py: get_all_entities().
822
845
 
823
846
  Update locations with thermostat ranking results and use
824
847
  the result to update the device_class of secondary thermostats.
@@ -826,22 +849,26 @@ class SmileHelper(SmileCommon):
826
849
  self._thermo_locs = self._match_locations()
827
850
 
828
851
  thermo_matching: dict[str, int] = {
829
- "thermostat": 3,
852
+ "thermostat": 2,
830
853
  "zone_thermometer": 2,
831
854
  "zone_thermostat": 2,
832
855
  "thermostatic_radiator_valve": 1,
833
856
  }
834
857
 
835
858
  for loc_id in self._thermo_locs:
836
- for dev_id, device in self.gw_devices.items():
837
- self._rank_thermostat(thermo_matching, loc_id, dev_id, device)
838
-
839
- # Update secondary thermostat class where needed
840
- for dev_id, device in self.gw_devices.items():
841
- if (loc_id := device["location"]) in self._thermo_locs:
842
- tl_loc_id = self._thermo_locs[loc_id]
843
- if "secondary" in tl_loc_id and dev_id in tl_loc_id["secondary"]:
844
- device["dev_class"] = "thermo_sensor"
859
+ for entity_id, entity in self.gw_entities.items():
860
+ self._rank_thermostat(thermo_matching, loc_id, entity_id, entity)
861
+
862
+ for loc_id, loc_data in list(self._thermo_locs.items()):
863
+ if loc_data["primary_prio"] != 0:
864
+ self._zones[loc_id] = {
865
+ "dev_class": "climate",
866
+ "model": "ThermoZone",
867
+ "name": loc_data["name"],
868
+ "thermostats": {"primary": loc_data["primary"], "secondary": loc_data["secondary"]},
869
+ "vendor": "Plugwise",
870
+ }
871
+ self._count += 3
845
872
 
846
873
  def _match_locations(self) -> dict[str, ThermoLoc]:
847
874
  """Helper-function for _scan_thermostats().
@@ -849,11 +876,11 @@ class SmileHelper(SmileCommon):
849
876
  Match appliances with locations.
850
877
  """
851
878
  matched_locations: dict[str, ThermoLoc] = {}
852
- for location_id, location_details in self.loc_data.items():
853
- for appliance_details in self.gw_devices.values():
879
+ for location_id, location_details in self._loc_data.items():
880
+ for appliance_details in self.gw_entities.values():
854
881
  if appliance_details["location"] == location_id:
855
882
  location_details.update(
856
- {"primary": None, "primary_prio": 0, "secondary": set()}
883
+ {"primary": [], "primary_prio": 0, "secondary": []}
857
884
  )
858
885
  matched_locations[location_id] = location_details
859
886
 
@@ -864,30 +891,34 @@ class SmileHelper(SmileCommon):
864
891
  thermo_matching: dict[str, int],
865
892
  loc_id: str,
866
893
  appliance_id: str,
867
- appliance_details: DeviceData,
894
+ appliance_details: GwEntityData,
868
895
  ) -> None:
869
896
  """Helper-function for _scan_thermostats().
870
897
 
871
898
  Rank the thermostat based on appliance_details: primary or secondary.
899
+ Note: there can be several primary and secondary thermostats.
872
900
  """
873
901
  appl_class = appliance_details["dev_class"]
874
902
  appl_d_loc = appliance_details["location"]
903
+ thermo_loc = self._thermo_locs[loc_id]
875
904
  if loc_id == appl_d_loc and appl_class in thermo_matching:
905
+ if thermo_matching[appl_class] == thermo_loc["primary_prio"]:
906
+ thermo_loc["primary"].append(appliance_id)
876
907
  # Pre-elect new primary
877
- if thermo_matching[appl_class] > self._thermo_locs[loc_id]["primary_prio"]:
908
+ elif (thermo_rank := thermo_matching[appl_class]) > thermo_loc["primary_prio"]:
909
+ thermo_loc["primary_prio"] = thermo_rank
878
910
  # Demote former primary
879
- if (tl_primary:= self._thermo_locs[loc_id]["primary"]) is not None:
880
- self._thermo_locs[loc_id]["secondary"].add(tl_primary)
911
+ if (tl_primary := thermo_loc["primary"]):
912
+ thermo_loc["secondary"] += tl_primary
913
+ thermo_loc["primary"] = []
881
914
 
882
915
  # Crown primary
883
- self._thermo_locs[loc_id]["primary_prio"] = thermo_matching[appl_class]
884
- self._thermo_locs[loc_id]["primary"] = appliance_id
885
-
916
+ thermo_loc["primary"].append(appliance_id)
886
917
  else:
887
- self._thermo_locs[loc_id]["secondary"].add(appliance_id)
918
+ thermo_loc["secondary"].append(appliance_id)
888
919
 
889
920
  def _control_state(self, loc_id: str) -> str | bool:
890
- """Helper-function for _device_data_adam().
921
+ """Helper-function for _get_adam_data().
891
922
 
892
923
  Adam: find the thermostat control_state of a location, from DOMAIN_OBJECTS.
893
924
  Represents the heating/cooling demand-state of the local primary thermostat.
@@ -902,7 +933,7 @@ class SmileHelper(SmileCommon):
902
933
  return False
903
934
 
904
935
  def _heating_valves(self) -> int | bool:
905
- """Helper-function for smile.py: _device_data_adam().
936
+ """Helper-function for smile.py: _get_adam_data().
906
937
 
907
938
  Collect amount of open valves indicating active direct heating.
908
939
  For cases where the heat is provided from an external shared source (city heating).
@@ -987,7 +1018,7 @@ class SmileHelper(SmileCommon):
987
1018
  return schedule_ids
988
1019
 
989
1020
  def _schedules(self, location: str) -> tuple[list[str], str]:
990
- """Helper-function for smile.py: _device_data_climate().
1021
+ """Helper-function for smile.py: _climate_data().
991
1022
 
992
1023
  Obtain the available schedules/schedules. Adam: a schedule can be connected to more than one location.
993
1024
  NEW: when a location_id is present then the schedule is active. Valid for both Adam and non-legacy Anna.