[remehaheating] Initial contribution (#19494)

* [remehaheating] Initial contribution

Signed-off-by: Michael Fraedrich <it-services@freddy4u.de>
This commit is contained in:
Michael F.
2025-12-23 13:20:51 +01:00
committed by GitHub
parent 8bbfd3a82c
commit ae7d7a8188
21 changed files with 1749 additions and 0 deletions
+1
View File
@@ -331,6 +331,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
+5
View File
@@ -1636,6 +1636,11 @@
<artifactId>org.openhab.binding.regoheatpump</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.remehaheating</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.remoteopenhab</artifactId>
@@ -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
@@ -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="<your-email@example.com>",
password="<your-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
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.remehaheating</artifactId>
<name>openHAB Add-ons :: Bundles :: RemehaHeating Binding</name>
</project>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.remehaheating-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-remehaheating" description="RemehaHeating Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.remehaheating/${project.version}</bundle>
</feature>
</features>
@@ -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";
}
@@ -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;
}
@@ -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;
}
}
@@ -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<ThingTypeUID> 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;
}
}
@@ -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;
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="remehaheating" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>Remeha Heating Binding</name>
<description>This binding integrates Remeha Home heating systems, allowing control and monitoring of boilers through
the Remeha Home cloud service.</description>
<connection>cloud</connection>
</addon:addon>
@@ -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
@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="remehaheating"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="boiler">
<label>Remeha Boiler</label>
<description>Remeha Home heating system boiler</description>
<category>heating</category>
<semantic-equipment-tag>Boiler</semantic-equipment-tag>
<channels>
<channel id="room-temperature" typeId="room-temperature"/>
<channel id="target-temperature" typeId="target-temperature"/>
<channel id="dhw-temperature" typeId="dhw-temperature"/>
<channel id="dhw-target" typeId="dhw-target"/>
<channel id="dhw-mode" typeId="dhw-mode"/>
<channel id="dhw-status" typeId="dhw-status"/>
<channel id="water-pressure" typeId="water-pressure"/>
<channel id="water-pressure-ok" typeId="water-pressure-ok"/>
<channel id="outdoor-temperature" typeId="outdoor-temperature"/>
<channel id="status" typeId="status"/>
</channels>
<config-description>
<parameter name="email" type="text" required="true">
<context>email</context>
<label>Email</label>
<description>Remeha Home account email</description>
</parameter>
<parameter name="password" type="text" required="true">
<context>password</context>
<label>Password</label>
<description>Remeha Home account password</description>
</parameter>
<parameter name="refreshInterval" type="integer" min="30" max="3600" unit="s">
<label>Refresh Interval</label>
<description>Interval to refresh data from Remeha API (from 30 to 3600 seconds)</description>
<default>60</default>
</parameter>
</config-description>
</thing-type>
<channel-type id="room-temperature">
<item-type unitHint="°C">Number:Temperature</item-type>
<label>Room Temperature</label>
<description>Current room temperature</description>
<category>temperature</category>
<tags>
<tag>Measurement</tag>
<tag>Temperature</tag>
</tags>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="target-temperature">
<item-type unitHint="°C">Number:Temperature</item-type>
<label>Target Temperature</label>
<description>Target room temperature</description>
<category>temperature</category>
<tags>
<tag>Setpoint</tag>
<tag>Temperature</tag>
</tags>
<state min="5" max="30" step="0.5" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="dhw-temperature">
<item-type unitHint="°C">Number:Temperature</item-type>
<label>Hot Water Temperature</label>
<description>Current hot water temperature</description>
<category>temperature</category>
<tags>
<tag>Measurement</tag>
<tag>Temperature</tag>
</tags>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="dhw-target">
<item-type unitHint="°C">Number:Temperature</item-type>
<label>Hot Water Target</label>
<description>Target hot water temperature</description>
<category>temperature</category>
<tags>
<tag>Setpoint</tag>
<tag>Temperature</tag>
</tags>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="water-pressure">
<item-type unitHint="bar">Number:Pressure</item-type>
<label>Water Pressure</label>
<description>System water pressure</description>
<category>pressure</category>
<tags>
<tag>Measurement</tag>
<tag>Pressure</tag>
</tags>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="outdoor-temperature">
<item-type unitHint="°C">Number:Temperature</item-type>
<label>Outdoor Temperature</label>
<description>Outdoor temperature</description>
<category>temperature</category>
<tags>
<tag>Measurement</tag>
<tag>Temperature</tag>
</tags>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="status">
<item-type>String</item-type>
<label>Status</label>
<description>Boiler status</description>
<tags>
<tag>Status</tag>
</tags>
<state readOnly="true"/>
</channel-type>
<channel-type id="dhw-mode">
<item-type>String</item-type>
<label>Hot Water Mode</label>
<description>Domestic hot water zone mode</description>
<tags>
<tag>Control</tag>
</tags>
<state>
<options>
<option value="anti-frost">Anti-frost</option>
<option value="schedule">Schedule</option>
<option value="continuous-comfort">Continuous Comfort</option>
</options>
</state>
</channel-type>
<channel-type id="dhw-status">
<item-type>String</item-type>
<label>Hot Water Status</label>
<description>Hot water status</description>
<tags>
<tag>Status</tag>
</tags>
<state readOnly="true"/>
</channel-type>
<channel-type id="water-pressure-ok">
<item-type>Switch</item-type>
<label>Water Pressure OK</label>
<description>Water pressure status</description>
<tags>
<tag>Status</tag>
</tags>
<state readOnly="true"/>
</channel-type>
</thing:thing-descriptions>
@@ -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
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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"
}
]
}
]
}
+1
View File
@@ -364,6 +364,7 @@
<module>org.openhab.binding.radiobrowser</module>
<module>org.openhab.binding.radiothermostat</module>
<module>org.openhab.binding.regoheatpump</module>
<module>org.openhab.binding.remehaheating</module>
<module>org.openhab.binding.revogi</module>
<module>org.openhab.binding.remoteopenhab</module>
<module>org.openhab.binding.renault</module>