diff --git a/CHANGELOG.md b/CHANGELOG.md index d2e640ddc..1a74b9a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v1.9.0 + +- Add support for Anna P1 via PR [#809](https://github.com/plugwise/python-plugwise/pull/809) + ## v1.8.3 - Remove storing the last active schedule(s) via PR [#806](https://github.com/plugwise/python-plugwise/pull/806), to be handled by the HA Integration diff --git a/README.md b/README.md index cc3dd4e75..9902b1102 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Module providing interfacing with the Plugwise devices: ### Smile - [x] Adam - - [x] Emma (only tested as ZigBee device connected to Adam) + - [x] Emma - [x] Jip - [x] Lisa - [x] Tom/Floor @@ -70,7 +70,7 @@ Module providing interfacing with the Plugwise devices: - [x] Plug - [x] Aqara Plug - [x] Anna (v1.8 and later firmware versions) -- [ ] Anna P1 +- [x] Anna P1 - [x] Smile P1 (v2.0 and later firmware versions) - [x] Stretch (only with Circles, please help out with other devices) diff --git a/fixtures/anna_p1/data.json b/fixtures/anna_p1/data.json new file mode 100644 index 000000000..fd0bc91fb --- /dev/null +++ b/fixtures/anna_p1/data.json @@ -0,0 +1,104 @@ +{ + "1e5e55b958ac445583602f767cb45942": { + "active_preset": "home", + "available_schedules": ["Thermostat schedule", "off"], + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "thermostat", + "firmware": "2018-02-08T11:15:53+01:00", + "hardware": "6539-1301-500", + "location": "5b13651d79c4454684fd268850b1bff8", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "off", + "sensors": { + "illuminance": 2.0, + "setpoint": 19.0, + "temperature": 19.4 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint": 19.0, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + }, + "36b937e44ad145bab165fa0fe99d742d": { + "available": true, + "binary_sensors": { + "dhw_state": false, + "flame_state": false, + "heating_state": false + }, + "dev_class": "heater_central", + "location": "da7be222ab3b420c927f3e49fade0304", + "model": "Generic heater", + "model_id": "HR24", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 0.0, + "modulation_level": 0.0, + "water_pressure": 6.0, + "water_temperature": 35.0 + }, + "switches": { + "dhw_cm_switch": true + }, + "vendor": "Intergas" + }, + "53130847be2f436cb946b78dedb9053a": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.4.4", + "hardware": "AME Smile 2.0 board", + "location": "da7be222ab3b420c927f3e49fade0304", + "mac_address": "C493000ABCD", + "model": "Gateway", + "model_id": "smile_thermo", + "name": "Smile Anna P1", + "notifications": {}, + "sensors": { + "outdoor_temperature": 11.8 + }, + "vendor": "Plugwise" + }, + "da7be222ab3b420c927f3e49fade0304": { + "available": true, + "dev_class": "smartmeter", + "location": "da7be222ab3b420c927f3e49fade0304", + "model": "2MS212 SMR5.5", + "name": "P1", + "sensors": { + "electricity_consumed_off_peak_cumulative": 618.001, + "electricity_consumed_off_peak_interval": 7, + "electricity_consumed_off_peak_point": 393, + "electricity_consumed_peak_cumulative": 576.014, + "electricity_consumed_peak_interval": 0, + "electricity_consumed_peak_point": 0, + "electricity_phase_one_consumed": 393, + "electricity_phase_one_produced": 0, + "electricity_produced_off_peak_cumulative": 246.504, + "electricity_produced_off_peak_interval": 0, + "electricity_produced_off_peak_point": 0, + "electricity_produced_peak_cumulative": 709.442, + "electricity_produced_peak_interval": 0, + "electricity_produced_peak_point": 0, + "gas_consumed_cumulative": 25.37, + "gas_consumed_interval": 0.01, + "net_electricity_cumulative": 238.069, + "net_electricity_point": 393, + "voltage_phase_one": 234.6 + }, + "vendor": "SAGEM" + } +} diff --git a/plugwise/__init__.py b/plugwise/__init__.py index 2b89b065f..bf4f3b04b 100644 --- a/plugwise/__init__.py +++ b/plugwise/__init__.py @@ -75,6 +75,7 @@ def __init__( self._stretch_v2 = False self._target_smile: str = NONE self.smile: Munch = Munch() + self.smile.anna_p1 = False self.smile.hostname = NONE self.smile.hw_version = None self.smile.legacy = False @@ -189,13 +190,23 @@ async def _smile_detect( """ model: str = "Unknown" if (gateway := result.find("./gateway")) is not None: - if (v_model := gateway.find("vendor_model")) is not None: - model = v_model.text self.smile.version = parse(gateway.find("firmware_version").text) self.smile.hw_version = gateway.find("hardware_version").text self.smile.hostname = gateway.find("hostname").text self.smile.mac_address = gateway.find("mac_address").text - self.smile.model_id = gateway.find("vendor_model").text + if (vendor_model := gateway.find("vendor_model")) is None: + return # pragma: no cover + + model = vendor_model.text + elec_measurement = gateway.find( + "gateway_environment/electricity_consumption_tariff_structure" + ) + if ( + elec_measurement is not None + and elec_measurement.text + and model == "smile_thermo" + ): + self.smile.anna_p1 = True else: model = await self._smile_detect_legacy(result, dsmrmain, model) @@ -231,29 +242,38 @@ async def _smile_detect( raise UnsupportedDeviceError # pragma: no cover self.smile.model = "Gateway" + self.smile.model_id = model self.smile.name = SMILES[self._target_smile].smile_name self.smile.type = SMILES[self._target_smile].smile_type + if self.smile.name == "Smile Anna" and self.smile.anna_p1: + self.smile.name = "Smile Anna P1" if self.smile.type == "stretch": self._stretch_v2 = int(version_major) == 2 - if self.smile.type == "thermostat": - self._is_thermostat = True - # For Adam, Anna, determine the system capabilities: - # Find the connected heating/cooling device (heater_central), - # e.g. heat-pump or gas-fired heater - onoff_boiler = result.find("./module/protocols/onoff_boiler") - open_therm_boiler = result.find("./module/protocols/open_therm_boiler") - self._on_off_device = onoff_boiler is not None - self._opentherm_device = open_therm_boiler is not None - - # Determine the presence of special features - locator_1 = "./gateway/features/cooling" - locator_2 = "./gateway/features/elga_support" - if result.find(locator_1) is not None: - self._cooling_present = True - if result.find(locator_2) is not None: - self._elga = True + self._process_for_thermostat(result) + + def _process_for_thermostat(self, result: etree.Element) -> None: + """Extra processing for thermostats.""" + if self.smile.type != "thermostat": + return + + self._is_thermostat = True + # For Adam, Anna, determine the system capabilities: + # Find the connected heating/cooling device (heater_central), + # e.g. heat-pump or gas-fired heater + onoff_boiler = result.find("./module/protocols/onoff_boiler") + open_therm_boiler = result.find("./module/protocols/open_therm_boiler") + self._on_off_device = onoff_boiler is not None + self._opentherm_device = open_therm_boiler is not None + + # Determine the presence of special features + locator_1 = "./gateway/features/cooling" + locator_2 = "./gateway/features/elga_support" + if result.find(locator_1) is not None: + self._cooling_present = True + if result.find(locator_2) is not None: + self._elga = True async def _smile_detect_legacy( self, result: etree.Element, dsmrmain: etree.Element, model: str diff --git a/plugwise/common.py b/plugwise/common.py index 2e6e46ae0..47a456266 100644 --- a/plugwise/common.py +++ b/plugwise/common.py @@ -64,8 +64,11 @@ def heater_id(self) -> str: return self._heater_id def check_name(self, name: str) -> bool: - """Helper-function checking the smile-name.""" - return bool(self.smile.name == name) + """Helper-function checking the smile-name. + + 20251101: modified for finding name = `Smile Anna` in `Smile Anna P1`. + """ + return bool(name in self.smile.name) def _appl_heater_central_info( self, @@ -98,9 +101,9 @@ def _appl_heater_central_info( # xml_1: appliance # xml_3: self._modules for legacy, self._domain_objects for actual xml_3 = return_valid(xml_3, self._domain_objects) - module_data = self._get_module_data(xml_1, locator_1, xml_3) + module_data = self._get_module_data(xml_1, locator_1, xml_2=xml_3) if not module_data["contents"]: - module_data = self._get_module_data(xml_1, locator_2, xml_3) + module_data = self._get_module_data(xml_1, locator_2, xml_2=xml_3) if not module_data["contents"]: self._heater_id = NONE return ( @@ -121,7 +124,7 @@ def _appl_thermostat_info( """Helper-function for _appliance_info_finder().""" locator = "./logs/point_log[type='thermostat']/thermostat" xml_2 = return_valid(xml_2, self._domain_objects) - module_data = self._get_module_data(xml_1, locator, xml_2) + module_data = self._get_module_data(xml_1, locator, xml_2=xml_2) if not module_data["contents"]: return Munch() # no module-data present means the device has been removed @@ -239,7 +242,8 @@ def _get_module_data( self, xml_1: etree.Element, locator: str, - xml_2: etree.Element = None, + key: str | None = None, + xml_2: etree.Element | None = None, legacy: bool = False, ) -> ModuleData: """Helper-function for _energy_device_info_finder() and _appliance_info_finder(). @@ -256,8 +260,11 @@ def _get_module_data( "zigbee_mac_address": None, } - if (appl_search := xml_1.find(locator)) is not None: + for appl_search in xml_1.findall(locator): link_tag = appl_search.tag + if key is not None and key not in link_tag: + continue + link_id = appl_search.attrib["id"] loc = f".//services/{link_tag}[@id='{link_id}']...." # Not possible to walrus for some reason... @@ -272,4 +279,6 @@ def _get_module_data( module_data["firmware_version"] = module.find("firmware_version").text get_zigbee_data(module, module_data, legacy) + break + return module_data diff --git a/plugwise/constants.py b/plugwise/constants.py index 1af524900..82db53377 100644 --- a/plugwise/constants.py +++ b/plugwise/constants.py @@ -34,6 +34,7 @@ ADAM: Final = "Adam" ANNA: Final = "Smile Anna" +ANNA_P1: Final = "Smile Anna P1" DEFAULT_TIMEOUT: Final = 10 DEFAULT_LEGACY_TIMEOUT: Final = 30 DEFAULT_USERNAME: Final = "smile" diff --git a/plugwise/helper.py b/plugwise/helper.py index 0d7355ee0..e4bfb1230 100644 --- a/plugwise/helper.py +++ b/plugwise/helper.py @@ -150,7 +150,7 @@ def _all_appliances(self) -> None: self._create_gw_entities(appl) - if self.smile.type == "power": + if self.smile.type == "power" or self.smile.anna_p1: self._get_p1_smartmeter_info() # Sort the gw_entities @@ -159,18 +159,21 @@ def _all_appliances(self) -> None: def _get_p1_smartmeter_info(self) -> None: """For P1 collect the connected SmartMeter info from the Home/building location. - Note: For P1, the entity_id for the gateway and smartmeter are - switched to maintain backward compatibility with existing implementations. + Note: For P1, the entity_id for the gateway and smartmeter are switched to maintain + backward compatibility. For Anna P1, the smartmeter uses the home location_id directly. """ appl = Munch() locator = MODULE_LOCATOR - module_data = self._get_module_data(self._home_location, locator) + tag = "electricity" + module_data = self._get_module_data(self._home_location, locator, key=tag) # No module-data present means the device has been removed if not module_data["contents"]: # pragma: no cover return appl.available = None - appl.entity_id = self._gateway_id + appl.entity_id = self._home_loc_id + if not self.smile.anna_p1: + appl.entity_id = self._gateway_id appl.firmware = module_data["firmware_version"] appl.hardware = module_data["hardware_version"] appl.location = self._home_loc_id @@ -183,8 +186,9 @@ def _get_p1_smartmeter_info(self) -> None: appl.zigbee_mac = None # Replace the entity_id of the gateway by the smartmeter location_id - self.gw_entities[self._home_loc_id] = self.gw_entities.pop(self._gateway_id) - self._gateway_id = self._home_loc_id + if not self.smile.anna_p1: + self.gw_entities[self._home_loc_id] = self.gw_entities.pop(self._gateway_id) + self._gateway_id = self._home_loc_id self._create_gw_entities(appl) @@ -323,11 +327,13 @@ def _get_measurement_data(self, entity_id: str) -> GwEntityData: data: GwEntityData = {"binary_sensors": {}, "sensors": {}, "switches": {}} # Get P1 smartmeter data from LOCATIONS entity = self.gw_entities[entity_id] - # !! DON'T CHANGE below two if-lines, will break stuff !! - if self.smile.type == "power": - if entity["dev_class"] == "smartmeter": - data.update(self._power_data_from_location()) + smile_is_power = self.smile.type == "power" + if (smile_is_power or self.smile.anna_p1) and entity.get( + "dev_class" + ) == "smartmeter": + data.update(self._power_data_from_location()) + if smile_is_power and not self.smile.anna_p1: return data # Get non-P1 data from APPLIANCES @@ -340,20 +346,13 @@ def _get_measurement_data(self, entity_id: str) -> GwEntityData: # Counting of this item is done in _appliance_measurements() if ( - appliance := self._domain_objects.find(f'./appliance[@id="{entity_id}"]') + appliance := self._collect_appliance_data( + data, entity, entity_id, measurements + ) ) is not None: - self._appliance_measurements(appliance, data, measurements) - self._get_lock_state(appliance, data) - - for toggle, name in TOGGLES.items(): - self._get_toggle_state(appliance, toggle, name, data) - - if appliance.find("type").text in ACTUATOR_CLASSES: - self._get_actuator_functionalities(appliance, entity, data) - - self._get_regulation_mode(appliance, entity_id, data) - self._get_gateway_mode(appliance, entity_id, data) - self._get_gateway_outdoor_temp(entity_id, data) + self._get_regulation_mode(appliance, entity_id, data) + self._get_gateway_mode(appliance, entity_id, data) + self._get_gateway_outdoor_temp(entity_id, data) if "c_heating_state" in data: self._process_c_heating_state(data) @@ -368,6 +367,30 @@ def _get_measurement_data(self, entity_id: str) -> GwEntityData: return data + def _collect_appliance_data( + self, + data: GwEntityData, + entity: GwEntityData, + entity_id: str, + measurements: dict[str, DATA | UOM], + ) -> etree.Element | None: + """Collect initial appliance data.""" + if ( + appliance := self._domain_objects.find(f'./appliance[@id="{entity_id}"]') + ) is not None: + self._appliance_measurements(appliance, data, measurements) + self._get_lock_state(appliance, data) + + for toggle, name in TOGGLES.items(): + self._get_toggle_state(appliance, toggle, name, data) + + if appliance.find("type").text in ACTUATOR_CLASSES: + self._get_actuator_functionalities(appliance, entity, data) + + return appliance + + return None + def _power_data_from_location(self) -> GwEntityData: """Helper-function for smile.py: _get_entity_data(). @@ -925,7 +948,6 @@ def _schedules(self, location: str) -> tuple[list[str], str]: if selected == NONE: selected = OFF - return available, selected def _thermostat_uri(self, loc_id: str) -> str: diff --git a/plugwise/legacy/helper.py b/plugwise/legacy/helper.py index 7185376b6..87e3d951b 100644 --- a/plugwise/legacy/helper.py +++ b/plugwise/legacy/helper.py @@ -212,7 +212,7 @@ def _energy_entity_info_finder(self, appliance: etree, appl: Munch) -> Munch: if self.smile.type in ("power", "stretch"): locator = "./services/electricity_point_meter" module_data = self._get_module_data( - appliance, locator, self._modules, legacy=True + appliance, locator, xml_2=self._modules, legacy=True ) if not module_data["contents"]: return ( diff --git a/plugwise/smile.py b/plugwise/smile.py index bfeb61817..362a2316d 100644 --- a/plugwise/smile.py +++ b/plugwise/smile.py @@ -361,9 +361,7 @@ async def set_schedule_state( await self.call_request(uri, method="put", data=data) self._schedule_old_states[loc_id][name] = new_state - def determine_contexts( - self, loc_id: str, state: str, sched_id: str - ) -> str: + def determine_contexts(self, loc_id: str, state: str, sched_id: str) -> str: """Helper-function for set_schedule_state().""" locator = f'.//*[@id="{sched_id}"]/contexts' contexts = self._domain_objects.find(locator) diff --git a/pyproject.toml b/pyproject.toml index c1268f779..f7addd0d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise" -version = "1.8.3" +version = "1.9.0" license = "MIT" description = "Plugwise Smile (Adam/Anna/P1) and Stretch module for Python 3." readme = "README.md" diff --git a/tests/data/anna/anna_p1.json b/tests/data/anna/anna_p1.json new file mode 100644 index 000000000..0e32474c0 --- /dev/null +++ b/tests/data/anna/anna_p1.json @@ -0,0 +1,106 @@ +{ + "devices": { + "1e5e55b958ac445583602f767cb45942": { + "active_preset": "home", + "available_schedules": ["Thermostat schedule", "off"], + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "thermostat", + "firmware": "2018-02-08T11:15:53+01:00", + "hardware": "6539-1301-500", + "location": "5b13651d79c4454684fd268850b1bff8", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "off", + "sensors": { + "illuminance": 2.0, + "setpoint": 19.0, + "temperature": 19.4 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint": 19.0, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + }, + "36b937e44ad145bab165fa0fe99d742d": { + "available": true, + "binary_sensors": { + "dhw_state": false, + "flame_state": false, + "heating_state": false + }, + "dev_class": "heater_central", + "location": "da7be222ab3b420c927f3e49fade0304", + "model": "Generic heater", + "model_id": "HR24", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 0.0, + "modulation_level": 0.0, + "water_pressure": 6.0, + "water_temperature": 35.0 + }, + "switches": { + "dhw_cm_switch": true + }, + "vendor": "Intergas" + }, + "da7be222ab3b420c927f3e49fade0304": { + "available": true, + "dev_class": "smartmeter", + "location": "da7be222ab3b420c927f3e49fade0304", + "model": "2MS212 SMR5.5", + "name": "P1", + "sensors": { + "electricity_consumed_off_peak_cumulative": 618.001, + "electricity_consumed_off_peak_interval": 7, + "electricity_consumed_off_peak_point": 393, + "electricity_consumed_peak_cumulative": 576.014, + "electricity_consumed_peak_interval": 0, + "electricity_consumed_peak_point": 0, + "electricity_phase_one_consumed": 393, + "electricity_phase_one_produced": 0, + "electricity_produced_off_peak_cumulative": 246.504, + "electricity_produced_off_peak_interval": 0, + "electricity_produced_off_peak_point": 0, + "electricity_produced_peak_cumulative": 709.442, + "electricity_produced_peak_interval": 0, + "electricity_produced_peak_point": 0, + "gas_consumed_cumulative": 25.37, + "gas_consumed_interval": 0.01, + "net_electricity_cumulative": 238.069, + "net_electricity_point": 393, + "voltage_phase_one": 234.6 + }, + "vendor": "SAGEM" + }, + "53130847be2f436cb946b78dedb9053a": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.4.4", + "hardware": "AME Smile 2.0 board", + "location": "da7be222ab3b420c927f3e49fade0304", + "mac_address": "C493000ABCD", + "model": "Gateway", + "model_id": "smile_thermo", + "name": "Smile Anna P1", + "notifications": {}, + "sensors": { + "outdoor_temperature": 11.8 + }, + "vendor": "Plugwise" + } + } +} diff --git a/tests/test_anna.py b/tests/test_anna.py index 6b5c65729..84fbcf27a 100644 --- a/tests/test_anna.py +++ b/tests/test_anna.py @@ -359,7 +359,9 @@ async def test_connect_anna_elga_2_schedule_off(self): assert self.entity_items == 65 result = await self.tinker_thermostat( - api, "d3ce834534114348be628b61b26d9220", good_schedules=["Thermostat schedule", "off"] + api, + "d3ce834534114348be628b61b26d9220", + good_schedules=["Thermostat schedule", "off"], ) assert result await api.close_connection() @@ -533,6 +535,27 @@ async def test_connect_anna_loria_driessens(self): await api.close_connection() await self.disconnect(server, client) + @pytest.mark.asyncio + async def test_connect_anna_p1(self): + """Test an Anna v4 connected to a P1 port.""" + self.smile_setup = "anna_p1" + + testdata = await self.load_testdata(SMILE_TYPE, self.smile_setup) + server, api, client = await self.connect_wrapper() + assert api.smile.hostname == "smile000000" + + self.validate_test_basics( + _LOGGER, + api, + smile_version=None, + ) + + await self.device_test(api, "2025-11-02 00:00:01", testdata) + assert self.entity_items == 76 + + await api.close_connection() + await self.disconnect(server, client) + @pytest.mark.asyncio async def test_connect_anna_v4_no_modules(self): """Test an Anna v4 with removed Anna and OpenTherm device.""" diff --git a/userdata/anna_p1/core.domain_objects.xml b/userdata/anna_p1/core.domain_objects.xml new file mode 100644 index 000000000..432ec093c --- /dev/null +++ b/userdata/anna_p1/core.domain_objects.xml @@ -0,0 +1,1508 @@ + + + + + 2021-12-18T11:59:13.471+01:00 + 2025-11-01T16:31:22.561+01:00 + + + + + + high_economy + off + small + underfloor_and_radiator + 120 + off + 24 + 10 + 0.7 + 0.7 + 1.5 + 75 + 55 + 0 + 20 + 7 + + + + Plugwise + ThermoExtension + 6539-1301-2304 + 2018-02-08T11:15:57+01:00 + 2021-12-18T11:59:05.771+01:00 + 2021-12-28T05:55:47.320+01:00 + + + + + + + + Plugwise + Gateway + AME Smile 2.0 board + + 2021-12-18T11:58:59.088+01:00 + 2022-06-22T02:59:00.904+02:00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Thermostat schedule + Provides a week schedule for a Location. +