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