diff --git a/CODEOWNERS b/CODEOWNERS index 215180a43ae49..83ad37dac608e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -328,6 +328,7 @@ /bundles/org.openhab.binding.radiobrowser/ @skinah /bundles/org.openhab.binding.radiothermostat/ @mlobstein /bundles/org.openhab.binding.regoheatpump/ @crnjan +/bundles/org.openhab.binding.remehaheating/ @FreddyFFM /bundles/org.openhab.binding.remoteopenhab/ @lolodomo /bundles/org.openhab.binding.renault/ @dougculnane /bundles/org.openhab.binding.resol/ @ramack diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index e67c844d38a74..520e75160ec71 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -1616,6 +1616,11 @@ org.openhab.binding.regoheatpump ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.remehaheating + ${project.version} + org.openhab.addons.bundles org.openhab.binding.remoteopenhab diff --git a/bundles/org.openhab.binding.remehaheating/NOTICE b/bundles/org.openhab.binding.remehaheating/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.remehaheating/README.md b/bundles/org.openhab.binding.remehaheating/README.md new file mode 100644 index 0000000000000..8d3852c8b9e7c --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/README.md @@ -0,0 +1,145 @@ +# RemehaHeating Binding + +This binding integrates Remeha Home heating systems with openHAB. +It connects to the Remeha cloud service using the same API as the official Remeha Home mobile app. + +The binding supports monitoring and control of Remeha boilers that are connected to the Remeha Home cloud service. +This includes most modern Remeha boilers with internet connectivity. + +Key features include: + +- Real-time monitoring of room and outdoor temperatures +- Target temperature control +- Hot water (DHW) temperature monitoring and mode control +- Water pressure monitoring and status +- System error status monitoring + +## Supported Things + +This binding supports Remeha boilers that are connected to the Remeha Home cloud service. + +- `boiler`: Represents a Remeha boiler with ThingTypeUID `remehaheating:boiler` + +The binding has been tested with Remeha Calenta Ace boilers but should work with any Remeha boiler that supports the Remeha Home cloud service. + +## Discovery + +This binding does not support automatic discovery. +Boilers must be manually configured using your Remeha Home account credentials. + +Each Remeha Home account typically manages one heating system, so you will need one Thing configuration per account. + +## Binding Configuration + +This binding does not require any global configuration. +All configuration is done at the Thing level using your Remeha Home account credentials. + +## Thing Configuration + +To configure a Remeha boiler, you need your Remeha Home account credentials. +These are the same credentials you use for the Remeha Home mobile app. + +### `boiler` Thing Configuration + +| Name | Type | Description | Default | Required | Advanced | +|-----------------|---------|-----------------------------------------------|---------|----------|----------| +| email | text | Remeha Home account email address | N/A | yes | no | +| password | text | Remeha Home account password | N/A | yes | no | +| refreshInterval | integer | Interval the device is polled in seconds | 60 | no | yes | + +The refresh interval should be set between 30 and 3600 seconds. +A shorter interval provides more up-to-date data but may increase API usage. + +## Channels + +The binding provides the following channels for monitoring and controlling your Remeha heating system: + +| Channel | Type | Read/Write | Description | +|---------------------|-------------------|------------|------------------------------------------------| +| room-temperature | Number:Temperature| Read | Current room temperature | +| target-temperature | Number:Temperature| Read/Write | Target room temperature (5-30°C) | +| dhw-temperature | Number:Temperature| Read | Current hot water temperature | +| dhw-target | Number:Temperature| Read | Target hot water temperature | +| dhw-mode | String | Read/Write | DHW mode (anti-frost/schedule/continuous-comfort) | +| dhw-status | String | Read | Hot water status | +| water-pressure | Number:Pressure | Read | System water pressure | +| water-pressure-ok | Switch | Read | Water pressure status (ON=OK, OFF=Low) | +| outdoor-temperature | Number:Temperature| Read | Outdoor temperature | +| status | String | Read | Boiler error status | + +## Full Example + +### Thing Configuration + +```java +Thing remehaheating:boiler:myboiler "Remeha Boiler" [ + email="", + password="", + refreshInterval=60 +] +``` + +### Item Configuration + +```java +// Temperature monitoring +Number:Temperature RoomTemp "Room Temperature [%.1f °C]" { channel="remehaheating:boiler:myboiler:room-temperature" } +Number:Temperature TargetTemp "Target Temperature [%.1f °C]" { channel="remehaheating:boiler:myboiler:target-temperature" } +Number:Temperature OutdoorTemp "Outdoor Temperature [%.1f °C]" { channel="remehaheating:boiler:myboiler:outdoor-temperature" } + +// Hot water +Number:Temperature DHWTemp "Hot Water Temperature [%.1f °C]" { channel="remehaheating:boiler:myboiler:dhw-temperature" } +Number:Temperature DHWTarget "Hot Water Target [%.1f °C]" { channel="remehaheating:boiler:myboiler:dhw-target" } +String DHWMode "Hot Water Mode [%s]" { channel="remehaheating:boiler:myboiler:dhw-mode" } +String DHWStatus "Hot Water Status [%s]" { channel="remehaheating:boiler:myboiler:dhw-status" } + +// System status +Number:Pressure WaterPressure "Water Pressure [%.1f bar]" { channel="remehaheating:boiler:myboiler:water-pressure" } +Switch WaterPressureOK "Water Pressure OK" { channel="remehaheating:boiler:myboiler:water-pressure-ok" } +String BoilerStatus "Boiler Status [%s]" { channel="remehaheating:boiler:myboiler:status" } +``` + +### Sitemap Configuration + +```perl +sitemap remeha label="Remeha Heating" { + Frame label="Temperature Control" { + Text item=RoomTemp + Setpoint item=TargetTemp minValue=5 maxValue=30 step=0.5 + Text item=OutdoorTemp + } + Frame label="Hot Water" { + Text item=DHWTemp + Text item=DHWTarget + Selection item=DHWMode mappings=["anti-frost"="Anti-frost", "schedule"="Schedule", "continuous-comfort"="Continuous Comfort"] + Text item=DHWStatus + } + Frame label="System Status" { + Text item=WaterPressure + Text item=WaterPressureOK + Text item=BoilerStatus + } +} +``` + +## Authentication + +This binding uses the same OAuth2 PKCE authentication flow as the official Remeha Home mobile app. +Your credentials are used only to obtain an access token and are not stored permanently. + +The binding automatically handles token refresh and re-authentication as needed. + +## Limitations + +- Only the first appliance from your Remeha Home account is supported +- Only the first climate zone and hot water zone are monitored +- The binding requires an active internet connection to the Remeha cloud service +- API rate limiting may apply - avoid setting very short refresh intervals + +## Troubleshooting + +- Ensure your Remeha Home account credentials are correct +- Check that your boiler is online in the Remeha Home mobile app +- Verify your openHAB system has internet connectivity +- Check the openHAB logs for authentication or API errors +- Try increasing the refresh interval if you experience connection issues diff --git a/bundles/org.openhab.binding.remehaheating/pom.xml b/bundles/org.openhab.binding.remehaheating/pom.xml new file mode 100644 index 0000000000000..c523d0d1c82a0 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 5.2.0-SNAPSHOT + + + org.openhab.binding.remehaheating + + openHAB Add-ons :: Bundles :: RemehaHeating Binding + + diff --git a/bundles/org.openhab.binding.remehaheating/src/main/feature/feature.xml b/bundles/org.openhab.binding.remehaheating/src/main/feature/feature.xml new file mode 100644 index 0000000000000..c48ebfb4c704d --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.remehaheating/${project.version} + + diff --git a/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingBindingConstants.java b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingBindingConstants.java new file mode 100644 index 0000000000000..cb5caf71e05e1 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingBindingConstants.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.remehaheating.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link RemehaHeatingBindingConstants} class defines common constants used across the binding. + * + * This class contains: + * - Thing type UIDs for supported devices + * - Channel identifiers for all supported channels + * - Configuration parameter names + * - DHW mode constants + * + * @author Michael Fraedrich - Initial contribution + */ +@NonNullByDefault +public class RemehaHeatingBindingConstants { + + private static final String BINDING_ID = "remehaheating"; + + // Thing Type UIDs + /** Thing type UID for Remeha boiler */ + public static final ThingTypeUID THING_TYPE_BOILER = new ThingTypeUID(BINDING_ID, "boiler"); + + // Channel identifiers + /** Current room temperature channel */ + public static final String CHANNEL_ROOM_TEMPERATURE = "room-temperature"; + /** Target room temperature channel (read/write) */ + public static final String CHANNEL_TARGET_TEMPERATURE = "target-temperature"; + /** Current DHW temperature channel */ + public static final String CHANNEL_DHW_TEMPERATURE = "dhw-temperature"; + /** Target DHW temperature channel */ + public static final String CHANNEL_DHW_TARGET = "dhw-target"; + /** System water pressure channel */ + public static final String CHANNEL_WATER_PRESSURE = "water-pressure"; + /** Outdoor temperature channel */ + public static final String CHANNEL_OUTDOOR_TEMPERATURE = "outdoor-temperature"; + /** Boiler error status channel */ + public static final String CHANNEL_STATUS = "status"; + /** DHW operating mode channel (read/write) */ + public static final String CHANNEL_DHW_MODE = "dhw-mode"; + /** Water pressure OK status channel */ + public static final String CHANNEL_WATER_PRESSURE_OK = "water-pressure-ok"; + /** DHW status channel */ + public static final String CHANNEL_DHW_STATUS = "dhw-status"; + + // Configuration parameter names + /** Email configuration parameter */ + public static final String CONFIG_EMAIL = "email"; + /** Password configuration parameter */ + public static final String CONFIG_PASSWORD = "password"; + /** Refresh interval configuration parameter */ + public static final String CONFIG_REFRESH_INTERVAL = "refreshInterval"; + + // DHW operating modes + /** Anti-frost DHW mode - minimal heating to prevent freezing */ + public static final String DHW_MODE_ANTI_FROST = "anti-frost"; + /** Schedule DHW mode - follows programmed schedule */ + public static final String DHW_MODE_SCHEDULE = "schedule"; + /** Continuous comfort DHW mode - maintains target temperature */ + public static final String DHW_MODE_CONTINUOUS_COMFORT = "continuous-comfort"; +} diff --git a/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingConfiguration.java b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingConfiguration.java new file mode 100644 index 0000000000000..15a6d1e447553 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.remehaheating.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link RemehaHeatingConfiguration} class contains fields mapping thing configuration parameters. + * + * This configuration class holds the parameters required to connect to a Remeha Home account: + * - Email and password for authentication + * - Refresh interval for periodic data updates + * + * @author Michael Fraedrich - Initial contribution + */ +@NonNullByDefault +public class RemehaHeatingConfiguration { + + /** + * Remeha Home account email address. + * This is the same email used for the Remeha Home mobile app. + */ + public String email = ""; + + /** + * Remeha Home account password. + * This is the same password used for the Remeha Home mobile app. + */ + public String password = ""; + + /** + * Refresh interval in seconds for polling the Remeha API. + * Default is 60 seconds. Valid range is 30-3600 seconds. + */ + public int refreshInterval = 60; +} diff --git a/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandler.java b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandler.java new file mode 100644 index 0000000000000..da962e0ba5be2 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandler.java @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.remehaheating.internal; + +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.*; + +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.remehaheating.internal.api.RemehaApiClient; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * The {@link RemehaHeatingHandler} handles communication with Remeha Home heating systems. + * + * This handler manages the connection to the Remeha API, authenticates using OAuth2 PKCE flow, + * and provides access to heating system data including temperatures, water pressure, and DHW controls. + * + * Supported features: + * - Room and outdoor temperature monitoring + * - Target temperature control + * - Hot water temperature and status monitoring + * - DHW mode control (anti-frost, schedule, continuous-comfort) + * - Water pressure monitoring + * - System status monitoring + * + * @author Michael Fraedrich - Initial contribution + */ +@NonNullByDefault +public class RemehaHeatingHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(RemehaHeatingHandler.class); + private final HttpClient httpClient; + private @Nullable RemehaApiClient apiClient; + private @Nullable ScheduledFuture refreshJob; + + public RemehaHeatingHandler(Thing thing, HttpClient httpClient) { + super(thing); + this.httpClient = httpClient; + } + + /** + * Handles commands sent to the binding channels. + * + * Supported commands: + * - RefreshType: Updates all channel states from API + * - DecimalType on targetTemperature: Sets new target room temperature + * - StringType on dhwMode: Changes DHW mode (anti-frost/schedule/continuous-comfort) + */ + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + updateData(); + } else if (CHANNEL_TARGET_TEMPERATURE.equals(channelUID.getId())) { + if (command instanceof QuantityType qt) { + QuantityType celsius = qt.toUnit(SIUnits.CELSIUS); + if (celsius != null) { + setTargetTemperature(celsius.doubleValue()); + } + } else if (command instanceof DecimalType dt) { + setTargetTemperature(dt.doubleValue()); + } + } else if (CHANNEL_DHW_MODE.equals(channelUID.getId()) && command instanceof StringType) { + setDhwMode(command.toString()); + } + } + + /** + * Initializes the handler by validating configuration and authenticating with Remeha API. + * Sets up periodic data refresh job on successful authentication. + */ + @Override + public void initialize() { + try { + RemehaHeatingConfiguration config = getConfigAs(RemehaHeatingConfiguration.class); + String email = config.email; + String password = config.password; + int refreshInterval = config.refreshInterval; + + if (email.isBlank() || password.isBlank()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.conf-error-no-credentials"); + return; + } + + updateStatus(ThingStatus.UNKNOWN); + apiClient = new RemehaApiClient(httpClient); + + scheduler.execute(() -> authenticateAndStart(email, password, refreshInterval)); + } catch (IllegalArgumentException e) { + logger.debug("Invalid configuration", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.conf-error-invalid-config"); + } + } + + private void authenticateAndStart(String email, String password, int refreshInterval) { + try { + RemehaApiClient client = apiClient; + if (client != null && client.authenticate(email, password)) { + updateStatus(ThingStatus.ONLINE); + startRefreshJob(refreshInterval > 0 ? refreshInterval : 60); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/offline.comm-error-authentication-failed"); + } + } catch (RuntimeException e) { + logger.debug("Authentication error", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/offline.comm-error-authentication-error"); + } + } + + /** + * Cleans up resources when the handler is disposed. + * Stops the refresh job. + */ + @Override + public void dispose() { + stopRefreshJob(); + apiClient = null; + try { + if (httpClient.isStarted()) { + httpClient.stop(); + } + } catch (Exception e) { + logger.debug("Error stopping HTTP client", e); + } + super.dispose(); + } + + /** + * Starts the periodic data refresh job. + * + * @param intervalSeconds Refresh interval in seconds + */ + private void startRefreshJob(int intervalSeconds) { + refreshJob = scheduler.scheduleWithFixedDelay(this::updateData, 0, intervalSeconds, TimeUnit.SECONDS); + } + + /** + * Stops the periodic data refresh job if running. + */ + private void stopRefreshJob() { + ScheduledFuture job = refreshJob; + if (job != null) { + job.cancel(true); + refreshJob = null; + } + } + + /** + * Fetches latest data from Remeha API and updates all channel states. + * + * Updates the following channels: + * - Room and outdoor temperatures + * - Target temperature + * - DHW temperature, target, mode, and status + * - Water pressure and pressure OK status + * - System error status + */ + private void updateData() { + RemehaApiClient client = apiClient; + if (client == null) { + return; + } + + try { + JsonObject dashboard = client.getDashboard(); + if (dashboard == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/offline.comm-error-data-fetch-failed"); + return; + } + + updateStatus(ThingStatus.ONLINE); + + JsonArray appliances = dashboard.getAsJsonArray("appliances"); + if (appliances != null && appliances.size() > 0) { + JsonObject appliance = appliances.get(0).getAsJsonObject(); + + // Update channels with proper units + double pressure = appliance.get("waterPressure").getAsDouble(); + logger.debug("Updating water pressure: {} bar", pressure); + updateState(CHANNEL_WATER_PRESSURE, new QuantityType<>(pressure, Units.BAR)); + updateState(CHANNEL_STATUS, new StringType(appliance.get("errorStatus").getAsString())); + updateState(CHANNEL_WATER_PRESSURE_OK, OnOffType.from(appliance.get("waterPressureOK").getAsBoolean())); + + JsonObject outdoorInfo = appliance.getAsJsonObject("outdoorTemperatureInformation"); + if (outdoorInfo != null) { + double temp = outdoorInfo.get("internetOutdoorTemperature").getAsDouble(); + updateState(CHANNEL_OUTDOOR_TEMPERATURE, new QuantityType<>(temp, SIUnits.CELSIUS)); + } + + // Climate zones + JsonArray climateZones = appliance.getAsJsonArray("climateZones"); + if (climateZones != null && climateZones.size() > 0) { + JsonObject zone = climateZones.get(0).getAsJsonObject(); + double roomTemp = zone.get("roomTemperature").getAsDouble(); + double targetTemp = zone.get("setPoint").getAsDouble(); + updateState(CHANNEL_ROOM_TEMPERATURE, new QuantityType<>(roomTemp, SIUnits.CELSIUS)); + updateState(CHANNEL_TARGET_TEMPERATURE, new QuantityType<>(targetTemp, SIUnits.CELSIUS)); + } + + // Hot water zones + JsonArray hotWaterZones = appliance.getAsJsonArray("hotWaterZones"); + if (hotWaterZones != null && hotWaterZones.size() > 0) { + JsonObject zone = hotWaterZones.get(0).getAsJsonObject(); + double dhwTemp = zone.get("dhwTemperature").getAsDouble(); + double dhwTarget = zone.get("targetSetpoint").getAsDouble(); + String dhwMode = zone.get("dhwZoneMode").getAsString(); + String dhwStatus = zone.get("dhwStatus").getAsString(); + updateState(CHANNEL_DHW_TEMPERATURE, new QuantityType<>(dhwTemp, SIUnits.CELSIUS)); + updateState(CHANNEL_DHW_TARGET, new QuantityType<>(dhwTarget, SIUnits.CELSIUS)); + updateState(CHANNEL_DHW_MODE, new StringType(dhwMode)); + updateState(CHANNEL_DHW_STATUS, new StringType(dhwStatus)); + } + } + } catch (IllegalStateException | NullPointerException e) { + logger.debug("Error updating data", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/offline.comm-error-data-update-failed"); + } + } + + /** + * Sets the target room temperature via API. + * + * @param temperature Target temperature in Celsius + */ + private void setTargetTemperature(double temperature) { + RemehaApiClient client = apiClient; + if (client != null) { + String climateZoneId = getClimateZoneId(); + if (climateZoneId != null) { + if (!client.setTemperature(climateZoneId, temperature)) { + logger.debug("Failed to set target temperature"); + } + } + } + } + + /** + * Sets the DHW (Domestic Hot Water) mode via API. + * + * @param mode DHW mode: "anti-frost", "schedule", or "continuous-comfort" + */ + private void setDhwMode(String mode) { + RemehaApiClient client = apiClient; + if (client != null) { + String hotWaterZoneId = getHotWaterZoneId(); + if (hotWaterZoneId != null) { + if (!client.setDhwMode(hotWaterZoneId, mode)) { + logger.debug("Failed to set DHW mode"); + } + } + } + } + + /** + * Retrieves the climate zone ID from the dashboard data. + * Used for temperature control API calls. + * + * @return Climate zone ID or null if not available + */ + private @Nullable String getClimateZoneId() { + RemehaApiClient client = apiClient; + if (client == null) { + return null; + } + + try { + JsonObject dashboard = client.getDashboard(); + if (dashboard != null) { + JsonArray appliances = dashboard.getAsJsonArray("appliances"); + if (appliances != null && appliances.size() > 0) { + JsonObject appliance = appliances.get(0).getAsJsonObject(); + JsonArray climateZones = appliance.getAsJsonArray("climateZones"); + if (climateZones != null && climateZones.size() > 0) { + return climateZones.get(0).getAsJsonObject().get("climateZoneId").getAsString(); + } + } + } + } catch (IllegalStateException | NullPointerException e) { + logger.debug("Error getting climate zone ID: {}", e.getMessage()); + } + return null; + } + + /** + * Retrieves the hot water zone ID from the dashboard data. + * Used for DHW control API calls. + * + * @return Hot water zone ID or null if not available + */ + private @Nullable String getHotWaterZoneId() { + RemehaApiClient client = apiClient; + if (client == null) { + return null; + } + + try { + JsonObject dashboard = client.getDashboard(); + if (dashboard != null) { + JsonArray appliances = dashboard.getAsJsonArray("appliances"); + if (appliances != null && appliances.size() > 0) { + JsonObject appliance = appliances.get(0).getAsJsonObject(); + JsonArray hotWaterZones = appliance.getAsJsonArray("hotWaterZones"); + if (hotWaterZones != null && hotWaterZones.size() > 0) { + return hotWaterZones.get(0).getAsJsonObject().get("hotWaterZoneId").getAsString(); + } + } + } + } catch (IllegalStateException | NullPointerException e) { + logger.debug("Error getting hot water zone ID: {}", e.getMessage()); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerFactory.java b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerFactory.java new file mode 100644 index 0000000000000..0f845e3f259a8 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerFactory.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.remehaheating.internal; + +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.THING_TYPE_BOILER; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link RemehaHeatingHandlerFactory} is responsible for creating things and thing handlers. + * + * This factory creates handlers for supported Remeha heating system things. + * It implements the OSGi component pattern and is automatically registered + * as a ThingHandlerFactory service. + * + * Currently supports: + * - Remeha boiler things (THING_TYPE_BOILER) + * + * @author Michael Fraedrich - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.remehaheating", service = ThingHandlerFactory.class) +public class RemehaHeatingHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BOILER); + private final HttpClientFactory httpClientFactory; + + @Activate + public RemehaHeatingHandlerFactory(@Reference HttpClientFactory httpClientFactory) { + this.httpClientFactory = httpClientFactory; + } + + /** + * Checks if this factory supports the given thing type. + * + * @param thingTypeUID The thing type UID to check + * @return true if the thing type is supported, false otherwise + */ + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + /** + * Creates a thing handler for the given thing. + * + * @param thing The thing for which to create a handler + * @return A new handler instance or null if the thing type is not supported + */ + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_BOILER.equals(thingTypeUID)) { + HttpClient httpClient = httpClientFactory.createHttpClient("remehaheating"); + httpClient.setRequestBufferSize(16384); + httpClient.setResponseBufferSize(16384); + try { + httpClient.start(); + } catch (Exception e) { + throw new IllegalStateException("Failed to start HTTP client", e); + } + return new RemehaHeatingHandler(thing, httpClient); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/api/RemehaApiClient.java b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/api/RemehaApiClient.java new file mode 100644 index 0000000000000..87387ac277007 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/api/RemehaApiClient.java @@ -0,0 +1,366 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.remehaheating.internal.api; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpMethod; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * The {@link RemehaApiClient} handles OAuth2 PKCE authentication and API communication with Remeha Home services. + * + * This client implements a custom OAuth2 PKCE authentication flow required by the Remeha API. + * The openHAB core OAuth2 client cannot be used because the Remeha API uses Azure B2C with a non-standard + * authentication flow that requires: + * - CSRF token extraction from authentication page cookies + * - Custom state properties (TID) handling for Azure B2C + * - Multi-step form submission with CSRF tokens + * - Manual authorization code extraction from redirect responses + * + * The standard OAuth2 Resource Owner Password Credentials flow is not supported by this Azure B2C + * configuration, and the authorization code flow requires programmatic interaction with the login form, + * which is not possible with the standard OAuth2 client. + * + * @author Michael Fraedrich - Initial contribution + */ +@NonNullByDefault +public class RemehaApiClient { + private final Logger logger = LoggerFactory.getLogger(RemehaApiClient.class); + private final HttpClient httpClient; + private final Gson gson = new Gson(); + private @Nullable String accessToken; + private String codeVerifier = ""; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final String API_BASE_URL = "https://api.bdrthermea.net/Mobile/api"; + private static final String SUBSCRIPTION_KEY = "df605c5470d846fc91e848b1cc653ddf"; + private static final long REQUEST_TIMEOUT_MS = 30000; + private static final Pattern CSRF_PATTERN = Pattern.compile("x-ms-cpim-csrf=([^;]+)"); + + /** + * Creates a new RemehaApiClient with the provided HttpClient. + * + * Note: This client requires custom buffer sizes (16384 bytes) to handle large OAuth2 responses + * from Azure B2C authentication. The HttpClient should be created via HttpClientFactory.createHttpClient() + * with buffer sizes configured in the factory, not using the common HTTP client. + * + * @param httpClient HttpClient instance with appropriate buffer sizes configured + */ + public RemehaApiClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * Authenticates with Remeha API using OAuth2 PKCE flow. + * + * This method performs the complete authentication sequence: + * 1. Generates PKCE code verifier and challenge + * 2. Initiates OAuth2 authorization request + * 3. Extracts CSRF token from response cookies + * 4. Submits user credentials + * 5. Retrieves authorization code from redirect + * 6. Exchanges authorization code for access token + * + * @param email Remeha Home account email + * @param password Remeha Home account password + * @return true if authentication successful, false otherwise + */ + public boolean authenticate(String email, String password) { + try { + codeVerifier = generateRandomString(); + String codeChallenge = generateCodeChallenge(codeVerifier); + String state = generateRandomString(); + + String authUrl = buildAuthUrl(codeChallenge, state); + Request authRequest = httpClient.newRequest(authUrl).method(HttpMethod.GET).timeout(REQUEST_TIMEOUT_MS, + TimeUnit.MILLISECONDS); + + ContentResponse response = authRequest.send(); + String requestId = response.getHeaders().get("x-request-id"); + String csrfToken = extractCsrfToken(response); + + if (csrfToken == null || requestId == null) { + logger.debug("Failed to extract CSRF token or request ID"); + return false; + } + + String stateProperties = createStateProperties(requestId); + if (!submitCredentials(email, password, csrfToken, stateProperties)) { + return false; + } + + String authCode = getAuthorizationCode(csrfToken, stateProperties); + if (authCode == null) { + return false; + } + + return exchangeCodeForToken(authCode); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.debug("Authentication interrupted", e); + return false; + } catch (Exception e) { + logger.debug("Authentication failed", e); + return false; + } + } + + /** + * Retrieves the dashboard data containing all heating system information. + * + * The dashboard includes: + * - Appliance information (boiler status, water pressure) + * - Climate zones (room temperature, target temperature) + * - Hot water zones (DHW temperature, mode, status) + * - Outdoor temperature information + * + * @return Dashboard JSON object or null if request fails + */ + public @Nullable JsonObject getDashboard() { + if (accessToken == null) { + return null; + } + try { + ContentResponse response = httpClient.newRequest(API_BASE_URL + "/homes/dashboard").method(HttpMethod.GET) + .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).header("Authorization", "Bearer " + accessToken) + .header("Ocp-Apim-Subscription-Key", SUBSCRIPTION_KEY).send(); + if (response.getStatus() == 401) { + logger.debug("Received 401 Unauthorized, token expired"); + accessToken = null; + return null; + } + return gson.fromJson(response.getContentAsString(), JsonObject.class); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.debug("Dashboard request interrupted", e); + return null; + } catch (Exception e) { + logger.debug("Failed to get dashboard", e); + return null; + } + } + + /** + * Sets the target room temperature for a climate zone. + * + * @param climateZoneId Climate zone identifier from dashboard data + * @param temperature Target temperature in Celsius + * @return true if request successful, false otherwise + */ + public boolean setTemperature(String climateZoneId, double temperature) { + return apiRequest("/climate-zones/" + climateZoneId + "/modes/manual", + "{\"roomTemperatureSetPoint\":" + temperature + "}"); + } + + /** + * Sets the DHW (Domestic Hot Water) operating mode. + * + * @param hotWaterZoneId Hot water zone identifier from dashboard data + * @param mode DHW mode: "anti-frost", "schedule", or "continuous-comfort" + * @return true if request successful, false otherwise + */ + public boolean setDhwMode(String hotWaterZoneId, String mode) { + return apiRequest("/hot-water-zones/" + hotWaterZoneId + "/modes/" + mode, null); + } + + private String generateCodeChallenge(String verifier) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(verifier.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } + + private String generateRandomString() { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String buildAuthUrl(String codeChallenge, String state) { + return "https://remehalogin.bdrthermea.net/bdrb2cprod.onmicrosoft.com/oauth2/v2.0/authorize" + + "?response_type=code" + "&client_id=6ce007c6-0628-419e-88f4-bee2e6418eec" + "&redirect_uri=" + + URLEncoder.encode("com.b2c.remehaapp://login-callback", StandardCharsets.UTF_8) + "&scope=" + + URLEncoder.encode( + "openid https://bdrb2cprod.onmicrosoft.com/iotdevice/user_impersonation offline_access", + StandardCharsets.UTF_8) + + "&state=" + state + "&code_challenge=" + codeChallenge + "&code_challenge_method=S256" + + "&p=B2C_1A_RPSignUpSignInNewRoomV3.1" + "&brand=remeha" + "&lang=en" + "&nonce=defaultNonce" + + "&prompt=login" + "&signUp=False"; + } + + private @Nullable String extractCsrfToken(ContentResponse response) { + HttpFields headers = response.getHeaders(); + logger.debug("Extracting CSRF token from cookies"); + for (String setCookieHeader : headers.getValuesList("Set-Cookie")) { + if (setCookieHeader != null && setCookieHeader.contains("x-ms-cpim-csrf=")) { + Matcher matcher = CSRF_PATTERN.matcher(setCookieHeader); + if (matcher.find()) { + String token = matcher.group(1); + logger.debug("CSRF token extracted from cookies"); + return token; + } + } + } + logger.debug("No CSRF token found in cookies"); + return null; + } + + private String createStateProperties(String requestId) { + String json = "{\"TID\":\"" + requestId + "\"}"; + return Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + private boolean submitCredentials(String email, String password, String csrfToken, String stateProperties) { + try { + String baseUrl = "https://remehalogin.bdrthermea.net/bdrb2cprod.onmicrosoft.com/B2C_1A_RPSignUpSignInNewRoomv3.1/SelfAsserted"; + + String formData = "request_type=RESPONSE" + "&signInName=" + + URLEncoder.encode(email, StandardCharsets.UTF_8) + "&password=" + + URLEncoder.encode(password, StandardCharsets.UTF_8); + + logger.debug("Submitting credentials with CSRF token"); + + Request request = httpClient.newRequest(baseUrl).method(HttpMethod.POST) + .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .param("tx", "StateProperties=" + stateProperties).param("p", "B2C_1A_RPSignUpSignInNewRoomv3.1") + .header("x-csrf-token", csrfToken).header("Content-Type", "application/x-www-form-urlencoded") + .content(new StringContentProvider(formData)); + + ContentResponse response = request.send(); + int status = response.getStatus(); + logger.debug("Submit credentials response: {}", status); + if (status != 200) { + logger.debug("Credential submission failed with status: {}", status); + } + return status == 200; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.debug("Credential submission interrupted", e); + return false; + } catch (Exception e) { + logger.debug("Failed to submit credentials", e); + return false; + } + } + + private @Nullable String getAuthorizationCode(String csrfToken, String stateProperties) { + try { + String baseUrl = "https://remehalogin.bdrthermea.net/bdrb2cprod.onmicrosoft.com/B2C_1A_RPSignUpSignInNewRoomv3.1/api/CombinedSigninAndSignup/confirmed"; + + Request request = httpClient.newRequest(baseUrl).method(HttpMethod.GET) + .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).param("rememberMe", "false") + .param("csrf_token", csrfToken).param("tx", "StateProperties=" + stateProperties) + .param("p", "B2C_1A_RPSignUpSignInNewRoomv3.1").followRedirects(false); + + ContentResponse response = request.send(); + logger.debug("Authorization code response status: {}", response.getStatus()); + + if (response.getStatus() == 302) { + String location = response.getHeaders().get("Location"); + logger.debug("Redirect location: {}", location); + if (location != null) { + Pattern pattern = Pattern.compile("code=([^&]+)"); + Matcher matcher = pattern.matcher(location); + if (matcher.find()) { + String authCode = matcher.group(1); + logger.debug("Authorization code successfully extracted."); + return authCode; + } + } + } else { + logger.debug("Expected 302 redirect, got {}", response.getStatus()); + } + } catch (Exception e) { + logger.debug("Failed to get authorization code: {}", e.getMessage()); + } + return null; + } + + private boolean exchangeCodeForToken(String authCode) { + try { + String url = "https://remehalogin.bdrthermea.net/bdrb2cprod.onmicrosoft.com/oauth2/v2.0/token?p=B2C_1A_RPSignUpSignInNewRoomV3.1"; + String formData = "grant_type=authorization_code&code=" + authCode + "&redirect_uri=" + + URLEncoder.encode("com.b2c.remehaapp://login-callback", StandardCharsets.UTF_8) + + "&code_verifier=" + codeVerifier + "&client_id=6ce007c6-0628-419e-88f4-bee2e6418eec"; + + Request request = httpClient.newRequest(url).method(HttpMethod.POST) + .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .header("Content-Type", "application/x-www-form-urlencoded") + .content(new StringContentProvider(formData)); + + ContentResponse response = request.send(); + if (response.getStatus() == 200) { + String json = response.getContentAsString(); + JsonObject tokenResponse = gson.fromJson(json, JsonObject.class); + if (tokenResponse != null && tokenResponse.has("access_token")) { + accessToken = tokenResponse.get("access_token").getAsString(); + logger.debug("Successfully obtained access token"); + return true; + } else { + logger.debug("Token response missing access_token field"); + } + } else { + logger.debug("Token exchange failed with status: {}", response.getStatus()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.debug("Token exchange interrupted", e); + } catch (java.util.concurrent.TimeoutException e) { + logger.debug("Token exchange timed out after {}ms", REQUEST_TIMEOUT_MS, e); + } catch (Exception e) { + logger.debug("Failed to exchange code for token: {}", e.getMessage()); + } + return false; + } + + private boolean apiRequest(String path, @Nullable String jsonData) { + if (accessToken == null) { + return false; + } + try { + Request request = httpClient.newRequest(API_BASE_URL + path).method(HttpMethod.POST) + .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).header("Authorization", "Bearer " + accessToken) + .header("Ocp-Apim-Subscription-Key", SUBSCRIPTION_KEY).header("Content-Type", "application/json"); + if (jsonData != null) { + request.content(new StringContentProvider(jsonData)); + } + int status = request.send().getStatus(); + if (status == 401) { + logger.debug("Received 401 Unauthorized, token expired"); + accessToken = null; + return false; + } + return status == 200; + } catch (Exception e) { + logger.debug("API request failed for {}: {}", path, e.getMessage()); + return false; + } + } +} diff --git a/bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 0000000000000..ed58eea54deea --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,12 @@ + + + + binding + Remeha Heating Binding + This binding integrates Remeha Home heating systems, allowing control and monitoring of boilers through + the Remeha Home cloud service. + cloud + + diff --git a/bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/i18n/remehaheating.properties b/bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/i18n/remehaheating.properties new file mode 100644 index 0000000000000..f5aed5d6bcfdd --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/i18n/remehaheating.properties @@ -0,0 +1,53 @@ +# add-on + +addon.remehaheating.name = Remeha Heating Binding +addon.remehaheating.description = This binding integrates Remeha Home heating systems, allowing control and monitoring of boilers through the Remeha Home cloud service. + +# thing types + +thing-type.remehaheating.boiler.label = Remeha Boiler +thing-type.remehaheating.boiler.description = Remeha Home heating system boiler + +# thing types config + +thing-type.config.remehaheating.boiler.email.label = Email +thing-type.config.remehaheating.boiler.email.description = Remeha Home account email +thing-type.config.remehaheating.boiler.password.label = Password +thing-type.config.remehaheating.boiler.password.description = Remeha Home account password +thing-type.config.remehaheating.boiler.refreshInterval.label = Refresh Interval +thing-type.config.remehaheating.boiler.refreshInterval.description = Interval to refresh data from Remeha API (from 30 to 3600 seconds) + +# channel types + +channel-type.remehaheating.dhw-mode.label = Hot Water Mode +channel-type.remehaheating.dhw-mode.description = Domestic hot water zone mode +channel-type.remehaheating.dhw-mode.state.option.anti-frost = Anti-frost +channel-type.remehaheating.dhw-mode.state.option.schedule = Schedule +channel-type.remehaheating.dhw-mode.state.option.continuous-comfort = Continuous Comfort +channel-type.remehaheating.dhw-status.label = Hot Water Status +channel-type.remehaheating.dhw-status.description = Hot water status +channel-type.remehaheating.dhw-target.label = Hot Water Target +channel-type.remehaheating.dhw-target.description = Target hot water temperature +channel-type.remehaheating.dhw-temperature.label = Hot Water Temperature +channel-type.remehaheating.dhw-temperature.description = Current hot water temperature +channel-type.remehaheating.outdoor-temperature.label = Outdoor Temperature +channel-type.remehaheating.outdoor-temperature.description = Outdoor temperature +channel-type.remehaheating.room-temperature.label = Room Temperature +channel-type.remehaheating.room-temperature.description = Current room temperature +channel-type.remehaheating.status.label = Status +channel-type.remehaheating.status.description = Boiler status +channel-type.remehaheating.target-temperature.label = Target Temperature +channel-type.remehaheating.target-temperature.description = Target room temperature +channel-type.remehaheating.water-pressure-ok.label = Water Pressure OK +channel-type.remehaheating.water-pressure-ok.description = Water pressure status +channel-type.remehaheating.water-pressure.label = Water Pressure +channel-type.remehaheating.water-pressure.description = System water pressure + +# thing status descriptions + +offline.conf-error-no-credentials = Email and password are required +offline.conf-error-invalid-config = Configuration error +offline.comm-error-authentication-failed = Authentication failed +offline.comm-error-authentication-error = Authentication error +offline.comm-error-data-fetch-failed = Failed to retrieve data from API +offline.comm-error-data-update-failed = Data update failed diff --git a/bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 0000000000000..3e389971dc6ff --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,163 @@ + + + + + + Remeha Home heating system boiler + heating + Boiler + + + + + + + + + + + + + + + + + email + + Remeha Home account email + + + password + + Remeha Home account password + + + + Interval to refresh data from Remeha API (from 30 to 3600 seconds) + 60 + + + + + + Number:Temperature + + Current room temperature + temperature + + Measurement + Temperature + + + + + + Number:Temperature + + Target room temperature + temperature + + Setpoint + Temperature + + + + + + Number:Temperature + + Current hot water temperature + temperature + + Measurement + Temperature + + + + + + Number:Temperature + + Target hot water temperature + temperature + + Setpoint + Temperature + + + + + + Number:Pressure + + System water pressure + pressure + + Measurement + Pressure + + + + + + Number:Temperature + + Outdoor temperature + temperature + + Measurement + Temperature + + + + + + String + + Boiler status + + Status + + + + + + String + + Domestic hot water zone mode + + Control + + + + + + + + + + + + String + + Hot water status + + Status + + + + + + Switch + + Water pressure status + + Status + + + + + diff --git a/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaApiClientTest.java b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaApiClientTest.java new file mode 100644 index 0000000000000..43c18a983130a --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaApiClientTest.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.remehaheating.internal; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.binding.remehaheating.internal.api.RemehaApiClient; +import org.openhab.core.io.net.http.HttpClientFactory; + +import com.google.gson.JsonObject; + +/** + * Unit tests for {@link RemehaApiClient}. + * + * @author Michael Fraedrich - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@NonNullByDefault +public class RemehaApiClientTest { + + private @Mock @NonNullByDefault({}) HttpClientFactory httpClientFactory; + private @Mock @NonNullByDefault({}) HttpClient httpClient; + private @Mock @NonNullByDefault({}) Request request; + private @Mock @NonNullByDefault({}) ContentResponse response; + private @NonNullByDefault({}) RemehaApiClient apiClient; + + @BeforeEach + public void setUp() { + apiClient = new RemehaApiClient(httpClient); + } + + @Test + public void testConstructor() { + assertNotNull(apiClient); + } + + @Test + public void testGetDashboardWithoutToken() { + JsonObject result = apiClient.getDashboard(); + assertNull(result); + } + + @Test + public void testSetTemperature() { + boolean result = apiClient.setTemperature("zone123", 21.5); + assertFalse(result); // Should return false without access token + } + + @Test + public void testSetDhwMode() { + boolean result = apiClient.setDhwMode("zone456", "schedule"); + assertFalse(result); // Should return false without access token + } +} diff --git a/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingBindingConstantsTest.java b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingBindingConstantsTest.java new file mode 100644 index 0000000000000..059573da03267 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingBindingConstantsTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.remehaheating.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link RemehaHeatingBindingConstants}. + * + * @author Michael Fraedrich - Initial contribution + */ +@NonNullByDefault +public class RemehaHeatingBindingConstantsTest { + + @Test + public void testThingTypeUID() { + assertEquals("remehaheating", RemehaHeatingBindingConstants.THING_TYPE_BOILER.getBindingId()); + assertEquals("boiler", RemehaHeatingBindingConstants.THING_TYPE_BOILER.getId()); + } + + @Test + public void testChannelConstants() { + assertEquals("room-temperature", RemehaHeatingBindingConstants.CHANNEL_ROOM_TEMPERATURE); + assertEquals("target-temperature", RemehaHeatingBindingConstants.CHANNEL_TARGET_TEMPERATURE); + assertEquals("dhw-temperature", RemehaHeatingBindingConstants.CHANNEL_DHW_TEMPERATURE); + assertEquals("dhw-target", RemehaHeatingBindingConstants.CHANNEL_DHW_TARGET); + assertEquals("water-pressure", RemehaHeatingBindingConstants.CHANNEL_WATER_PRESSURE); + assertEquals("outdoor-temperature", RemehaHeatingBindingConstants.CHANNEL_OUTDOOR_TEMPERATURE); + assertEquals("status", RemehaHeatingBindingConstants.CHANNEL_STATUS); + assertEquals("dhw-mode", RemehaHeatingBindingConstants.CHANNEL_DHW_MODE); + assertEquals("water-pressure-ok", RemehaHeatingBindingConstants.CHANNEL_WATER_PRESSURE_OK); + assertEquals("dhw-status", RemehaHeatingBindingConstants.CHANNEL_DHW_STATUS); + } + + @Test + public void testConfigConstants() { + assertEquals("email", RemehaHeatingBindingConstants.CONFIG_EMAIL); + assertEquals("password", RemehaHeatingBindingConstants.CONFIG_PASSWORD); + assertEquals("refreshInterval", RemehaHeatingBindingConstants.CONFIG_REFRESH_INTERVAL); + } + + @Test + public void testDhwModeConstants() { + assertEquals("anti-frost", RemehaHeatingBindingConstants.DHW_MODE_ANTI_FROST); + assertEquals("schedule", RemehaHeatingBindingConstants.DHW_MODE_SCHEDULE); + assertEquals("continuous-comfort", RemehaHeatingBindingConstants.DHW_MODE_CONTINUOUS_COMFORT); + } +} diff --git a/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingConfigurationTest.java b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingConfigurationTest.java new file mode 100644 index 0000000000000..2fd49188f73e6 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingConfigurationTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.remehaheating.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link RemehaHeatingConfiguration}. + * + * @author Michael Fraedrich - Initial contribution + */ +@NonNullByDefault +public class RemehaHeatingConfigurationTest { + + @Test + public void testDefaultValues() { + RemehaHeatingConfiguration config = new RemehaHeatingConfiguration(); + + assertEquals("", config.email); + assertEquals("", config.password); + assertEquals(60, config.refreshInterval); + } + + @Test + public void testConfigurationValues() { + RemehaHeatingConfiguration config = new RemehaHeatingConfiguration(); + + config.email = "test@example.com"; + config.password = "testpassword"; + config.refreshInterval = 120; + + assertEquals("test@example.com", config.email); + assertEquals("testpassword", config.password); + assertEquals(120, config.refreshInterval); + } +} diff --git a/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerFactoryTest.java b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerFactoryTest.java new file mode 100644 index 0000000000000..137c5bef4f7be --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerFactoryTest.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.remehaheating.internal; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.ThingHandler; + +/** + * Unit tests for {@link RemehaHeatingHandlerFactory}. + * + * @author Michael Fraedrich - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@NonNullByDefault +public class RemehaHeatingHandlerFactoryTest { + + private @Mock @NonNullByDefault({}) Thing thing; + private @Mock @NonNullByDefault({}) HttpClientFactory httpClientFactory; + private @Mock @NonNullByDefault({}) org.eclipse.jetty.client.HttpClient httpClient; + private @NonNullByDefault({}) RemehaHeatingHandlerFactory factory; + + @BeforeEach + public void setUp() { + lenient().when(httpClientFactory.createHttpClient("remehaheating")).thenReturn(httpClient); + factory = new RemehaHeatingHandlerFactory(httpClientFactory); + } + + @Test + public void testSupportsThingType() { + assertTrue(factory.supportsThingType(RemehaHeatingBindingConstants.THING_TYPE_BOILER)); + assertFalse(factory.supportsThingType(new ThingTypeUID("other", "thing"))); + } + + @Test + public void testCreateHandler() { + when(thing.getThingTypeUID()).thenReturn(RemehaHeatingBindingConstants.THING_TYPE_BOILER); + + ThingHandler handler = factory.createHandler(thing); + + assertNotNull(handler); + assertInstanceOf(RemehaHeatingHandler.class, handler); + verify(httpClient).setRequestBufferSize(16384); + verify(httpClient).setResponseBufferSize(16384); + } + + @Test + public void testCreateHandlerForUnsupportedThing() { + when(thing.getThingTypeUID()).thenReturn(new ThingTypeUID("other", "thing")); + + ThingHandler handler = factory.createHandler(thing); + + assertNull(handler); + } +} diff --git a/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerTest.java b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerTest.java new file mode 100644 index 0000000000000..0e7ec2ab7b2f7 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.remehaheating.internal; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.lenient; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandlerCallback; +import org.openhab.core.types.RefreshType; + +/** + * Unit tests for {@link RemehaHeatingHandler}. + * + * @author Michael Fraedrich - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@NonNullByDefault +public class RemehaHeatingHandlerTest { + + private @Mock @NonNullByDefault({}) Thing thing; + private @Mock @NonNullByDefault({}) ThingHandlerCallback callback; + private @Mock @NonNullByDefault({}) Configuration configuration; + private @Mock @NonNullByDefault({}) org.eclipse.jetty.client.HttpClient httpClient; + private @NonNullByDefault({}) RemehaHeatingHandler handler; + private @NonNullByDefault({}) ThingUID thingUID; + private @NonNullByDefault({}) ChannelUID channelUID; + + @BeforeEach + public void setUp() { + thingUID = new ThingUID(RemehaHeatingBindingConstants.THING_TYPE_BOILER, "test"); + channelUID = new ChannelUID(thingUID, RemehaHeatingBindingConstants.CHANNEL_TARGET_TEMPERATURE); + + lenient().when(thing.getUID()).thenReturn(thingUID); + lenient().when(thing.getConfiguration()).thenReturn(configuration); + + handler = new RemehaHeatingHandler(thing, httpClient); + handler.setCallback(callback); + } + + @Test + public void testConstructor() { + assertNotNull(handler); + } + + @Test + public void testInitializeWithMissingCredentials() { + RemehaHeatingConfiguration config = new RemehaHeatingConfiguration(); + config.email = ""; + config.password = ""; + + when(configuration.as(RemehaHeatingConfiguration.class)).thenReturn(config); + + handler.initialize(); + + verify(callback).statusUpdated(eq(thing), argThat(status -> status.getStatus() == ThingStatus.OFFLINE + && status.getStatusDetail() == ThingStatusDetail.CONFIGURATION_ERROR)); + } + + @Test + public void testHandleRefreshCommand() { + ChannelUID channelUID = new ChannelUID(thingUID, RemehaHeatingBindingConstants.CHANNEL_ROOM_TEMPERATURE); + + // Should not throw exception + assertDoesNotThrow(() -> handler.handleCommand(channelUID, RefreshType.REFRESH)); + } + + @Test + public void testHandleTargetTemperatureCommand() { + DecimalType temperature = new DecimalType(21.5); + + // Should not throw exception + assertDoesNotThrow(() -> handler.handleCommand(channelUID, temperature)); + } + + @Test + public void testHandleDhwModeCommand() { + ChannelUID dhwChannelUID = new ChannelUID(thingUID, RemehaHeatingBindingConstants.CHANNEL_DHW_MODE); + StringType mode = new StringType("schedule"); + + // Should not throw exception + assertDoesNotThrow(() -> handler.handleCommand(dhwChannelUID, mode)); + } + + @Test + public void testDispose() { + // Should not throw exception + assertDoesNotThrow(() -> handler.dispose()); + } +} diff --git a/bundles/org.openhab.binding.remehaheating/src/test/resources/dashboard-sample.json b/bundles/org.openhab.binding.remehaheating/src/test/resources/dashboard-sample.json new file mode 100644 index 0000000000000..e7b290d65dece --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/test/resources/dashboard-sample.json @@ -0,0 +1,28 @@ +{ + "appliances": [ + { + "waterPressure": 1.5, + "errorStatus": "OK", + "waterPressureOK": true, + "outdoorTemperatureInformation": { + "internetOutdoorTemperature": 15.2 + }, + "climateZones": [ + { + "climateZoneId": "zone123", + "roomTemperature": 20.5, + "setPoint": 21.0 + } + ], + "hotWaterZones": [ + { + "hotWaterZoneId": "zone456", + "dhwTemperature": 45.0, + "targetSetpoint": 50.0, + "dhwZoneMode": "schedule", + "dhwStatus": "heating" + } + ] + } + ] +} \ No newline at end of file diff --git a/bundles/pom.xml b/bundles/pom.xml index d587a5f67ad0b..f94d8b2a334e9 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -361,6 +361,7 @@ org.openhab.binding.radiobrowser org.openhab.binding.radiothermostat org.openhab.binding.regoheatpump + org.openhab.binding.remehaheating org.openhab.binding.revogi org.openhab.binding.remoteopenhab org.openhab.binding.renault