From 75ced4de7d857f24f8da286e58a5826ae7820ed3 Mon Sep 17 00:00:00 2001 From: Michael Fraedrich Date: Fri, 17 Oct 2025 11:16:09 +0200 Subject: [PATCH 01/11] [remehaheating] Initial contribution Add binding for Remeha Home heating systems. Supports monitoring and control of Remeha boilers connected to the Remeha cloud service. Features: - Temperature monitoring and control - Hot water (DHW) management - System status monitoring - OAuth2 PKCE authentication Signed-off-by: Michael Fraedrich --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + .../org.openhab.binding.remehaheating/NOTICE | 13 + .../README.md | 145 ++++++ .../org.openhab.binding.remehaheating/pom.xml | 17 + .../src/main/feature/feature.xml | 9 + .../internal/RemehaApiClient.java | 427 ++++++++++++++++++ .../RemehaHeatingBindingConstants.java | 77 ++++ .../internal/RemehaHeatingConfiguration.java | 46 ++ .../internal/RemehaHeatingHandler.java | 351 ++++++++++++++ .../internal/RemehaHeatingHandlerFactory.java | 73 +++ .../src/main/resources/OH-INF/addon/addon.xml | 12 + .../OH-INF/i18n/remehaheating.properties | 44 ++ .../resources/OH-INF/thing/thing-types.xml | 141 ++++++ .../internal/RemehaApiClientTest.java | 87 ++++ .../RemehaHeatingBindingConstantsTest.java | 59 +++ .../RemehaHeatingConfigurationTest.java | 47 ++ .../RemehaHeatingHandlerFactoryTest.java | 72 +++ .../internal/RemehaHeatingHandlerTest.java | 115 +++++ .../src/test/resources/dashboard-sample.json | 28 ++ bundles/pom.xml | 16 +- 21 files changed, 1771 insertions(+), 14 deletions(-) create mode 100644 bundles/org.openhab.binding.remehaheating/NOTICE create mode 100644 bundles/org.openhab.binding.remehaheating/README.md create mode 100644 bundles/org.openhab.binding.remehaheating/pom.xml create mode 100644 bundles/org.openhab.binding.remehaheating/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaApiClient.java create mode 100644 bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingBindingConstants.java create mode 100644 bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingConfiguration.java create mode 100644 bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandler.java create mode 100644 bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerFactory.java create mode 100644 bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/addon/addon.xml create mode 100644 bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/i18n/remehaheating.properties create mode 100644 bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/thing/thing-types.xml create mode 100644 bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaApiClientTest.java create mode 100644 bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingBindingConstantsTest.java create mode 100644 bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingConfigurationTest.java create mode 100644 bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerFactoryTest.java create mode 100644 bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerTest.java create mode 100644 bundles/org.openhab.binding.remehaheating/src/test/resources/dashboard-sample.json 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..b9ffd37345f72 --- /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 Tzerra 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 | +|---------------------|-------------------|------------|------------------------------------------------| +| roomTemperature | Number:Temperature| Read | Current room temperature | +| targetTemperature | Number:Temperature| Read/Write | Target room temperature (5-30°C) | +| dhwTemperature | Number:Temperature| Read | Current hot water temperature | +| dhwTarget | Number:Temperature| Read | Target hot water temperature | +| dhwMode | String | Read/Write | DHW mode (anti-frost/schedule/continuous-comfort) | +| dhwStatus | String | Read | Hot water status | +| waterPressure | Number:Pressure | Read | System water pressure | +| waterPressureOK | Switch | Read | Water pressure status (ON=OK, OFF=Low) | +| outdoorTemperature | 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:roomTemperature" } +Number:Temperature TargetTemp "Target Temperature [%.1f °C]" { channel="remehaheating:boiler:myboiler:targetTemperature" } +Number:Temperature OutdoorTemp "Outdoor Temperature [%.1f °C]" { channel="remehaheating:boiler:myboiler:outdoorTemperature" } + +// Hot water +Number:Temperature DHWTemp "Hot Water Temperature [%.1f °C]" { channel="remehaheating:boiler:myboiler:dhwTemperature" } +Number:Temperature DHWTarget "Hot Water Target [%.1f °C]" { channel="remehaheating:boiler:myboiler:dhwTarget" } +String DHWMode "Hot Water Mode [%s]" { channel="remehaheating:boiler:myboiler:dhwMode" } +String DHWStatus "Hot Water Status [%s]" { channel="remehaheating:boiler:myboiler:dhwStatus" } + +// System status +Number:Pressure WaterPressure "Water Pressure [%.1f bar]" { channel="remehaheating:boiler:myboiler:waterPressure" } +Switch WaterPressureOK "Water Pressure OK" { channel="remehaheating:boiler:myboiler:waterPressureOK" } +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..00f8dcd0f0499 --- /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.1.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/RemehaApiClient.java b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaApiClient.java new file mode 100644 index 0000000000000..e37e0fc77df7d --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaApiClient.java @@ -0,0 +1,427 @@ +/* + * 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 java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +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.openhab.core.io.net.http.HttpClientFactory; +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 the complete OAuth2 PKCE (Proof Key for Code Exchange) authentication flow + * required by the Remeha API, including: + * - CSRF token extraction from authentication pages + * - State properties handling for Azure B2C + * - Authorization code exchange for access tokens + * - Authenticated API requests for heating system control + * + * The authentication flow matches the official Remeha mobile app implementation. + * + * @author Michael Fraedrich - Initial contribution + */ +@NonNullByDefault +public class RemehaApiClient { + private final Logger logger = LoggerFactory.getLogger(RemehaApiClient.class); + private HttpClient httpClient; + private final Gson gson = new Gson(); + private @Nullable String accessToken; + private String codeVerifier = ""; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + public RemehaApiClient(HttpClientFactory httpClientFactory) { + this.httpClient = httpClientFactory.getCommonHttpClient(); + } + + public RemehaApiClient() { + org.eclipse.jetty.util.ssl.SslContextFactory.Client sslContextFactory = new org.eclipse.jetty.util.ssl.SslContextFactory.Client(); + sslContextFactory.setTrustAll(true); + + this.httpClient = new HttpClient(sslContextFactory); + + try { + this.httpClient.setRequestBufferSize(16384); + this.httpClient.setResponseBufferSize(16384); + this.httpClient.start(); + } catch (Exception e) { + logger.debug("Failed to start HTTP client", e); + throw new IllegalStateException("HTTP client initialization failed", e); + } + } + + /** + * 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); + + 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("https://api.bdrthermea.net/Mobile/api/homes/dashboard") + .method(HttpMethod.GET).header("Authorization", "Bearer " + accessToken) + .header("Ocp-Apim-Subscription-Key", "df605c5470d846fc91e848b1cc653ddf").send(); + 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; + } + } + + /** + * Generates SHA256-based code challenge for PKCE flow. + * + * @param verifier Code verifier string + * @return Base64-encoded SHA256 hash of verifier + * @throws Exception if SHA256 algorithm not available + */ + 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); + } + + /** + * Generates a cryptographically secure random string for PKCE parameters. + * + * @return Base64-encoded random string + */ + private String generateRandomString() { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * Builds the OAuth2 authorization URL with all required parameters. + * + * @param codeChallenge PKCE code challenge + * @param state OAuth2 state parameter + * @return Complete authorization URL + */ + 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"; + } + + /** + * Extracts CSRF token from Set-Cookie headers in authentication response. + * + * @param response HTTP response from authorization endpoint + * @return CSRF token or null if not found + */ + private static final Pattern CSRF_PATTERN = Pattern.compile("x-ms-cpim-csrf=([^;]+)"); + + 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("Extracted CSRF token length: {}", token.length()); + return token; + } + } + } + logger.debug("No CSRF token found in cookies"); + return null; + } + + /** + * Creates Base64-encoded state properties for Azure B2C authentication. + * + * @param requestId Request ID from authentication response + * @return Base64-encoded JSON state properties + */ + private String createStateProperties(String requestId) { + String json = "{\"TID\":\"" + requestId + "\"}"; + return Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Submits user credentials to the authentication endpoint. + * + * @param email User email address + * @param password User password + * @param csrfToken CSRF token from previous request + * @param stateProperties Base64-encoded state properties + * @return true if credentials accepted, false otherwise + */ + 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); + + // amazonq-ignore-next-line + logger.debug("Submitting credentials with CSRF token length: {}", csrfToken.length()); + + Request request = httpClient.newRequest(baseUrl).method(HttpMethod.POST) + .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; + } + } + + /** + * Retrieves authorization code from authentication redirect. + * + * @param csrfToken CSRF token from authentication flow + * @param stateProperties Base64-encoded state properties + * @return Authorization code or null if not found + */ + 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).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("Extracted auth code: {}...", + authCode.substring(0, Math.min(10, authCode.length()))); + 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; + } + + /** + * Exchanges authorization code for access token. + * + * @param authCode Authorization code from redirect + * @return true if token exchange successful, false otherwise + */ + 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) + .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 (Exception e) { + logger.debug("Failed to exchange code for token: {}", e.getMessage()); + } + return false; + } + + /** + * Makes an authenticated API request to the Remeha service. + * + * @param path API endpoint path (e.g., "/climate-zones/123/modes/manual") + * @param jsonData JSON payload for POST requests, null for requests without body + * @return true if request successful (HTTP 200), false otherwise + */ + private boolean apiRequest(String path, @Nullable String jsonData) { + if (accessToken == null) { + return false; + } + try { + Request request = httpClient.newRequest("https://api.bdrthermea.net/Mobile/api" + path) + .method(HttpMethod.POST).header("Authorization", "Bearer " + accessToken) + .header("Ocp-Apim-Subscription-Key", "df605c5470d846fc91e848b1cc653ddf") + .header("Content-Type", "application/json"); + if (jsonData != null) { + request.content(new StringContentProvider(jsonData)); + } + return request.send().getStatus() == 200; + } catch (Exception e) { + logger.debug("API request failed for {}: {}", path, e.getMessage()); + return false; + } + } + + /** + * 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); + } + + /** + * Closes the HTTP client and releases resources. + * + * @throws IOException if error occurs during shutdown + */ + public void close() throws IOException { + try { + if (httpClient != null && httpClient.isStarted()) { + httpClient.stop(); + } + } catch (Exception e) { + logger.debug("Error stopping HTTP client: {}", e.getMessage()); + } + } +} 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..08084514e0d3e --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingBindingConstants.java @@ -0,0 +1,77 @@ +/* + * 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 = "roomTemperature"; + /** Target room temperature channel (read/write) */ + public static final String CHANNEL_TARGET_TEMPERATURE = "targetTemperature"; + /** Current DHW temperature channel */ + public static final String CHANNEL_DHW_TEMPERATURE = "dhwTemperature"; + /** Target DHW temperature channel */ + public static final String CHANNEL_DHW_TARGET = "dhwTarget"; + /** System water pressure channel */ + public static final String CHANNEL_WATER_PRESSURE = "waterPressure"; + /** Outdoor temperature channel */ + public static final String CHANNEL_OUTDOOR_TEMPERATURE = "outdoorTemperature"; + /** Boiler error status channel */ + public static final String CHANNEL_STATUS = "status"; + /** DHW operating mode channel (read/write) */ + public static final String CHANNEL_DHW_MODE = "dhwMode"; + /** Water pressure OK status channel */ + public static final String CHANNEL_WATER_PRESSURE_OK = "waterPressureOK"; + /** DHW status channel */ + public static final String CHANNEL_DHW_STATUS = "dhwStatus"; + /** DHW boost mode channel (currently disabled) */ + // public static final String CHANNEL_DHW_BOOST = "dhwBoost"; + + // 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..6288f247bee8b --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandler.java @@ -0,0 +1,351 @@ +/* + * 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.CHANNEL_DHW_MODE; +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_DHW_STATUS; +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_DHW_TARGET; +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_DHW_TEMPERATURE; +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_OUTDOOR_TEMPERATURE; +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_ROOM_TEMPERATURE; +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_STATUS; +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_TARGET_TEMPERATURE; +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_WATER_PRESSURE; +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_WATER_PRESSURE_OK; + +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +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 @Nullable RemehaApiClient apiClient; + private @Nullable ScheduledFuture refreshJob; + private @Nullable RemehaHeatingConfiguration config; + + public RemehaHeatingHandler(Thing thing) { + super(thing); + } + + /** + * 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()) && command instanceof DecimalType) { + setTargetTemperature(((DecimalType) command).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 { + config = getConfigAs(RemehaHeatingConfiguration.class); + String email = config.email; + String password = config.password; + int refreshInterval = config.refreshInterval; + + if (email.isEmpty() || password.isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Email and password required"); + return; + } + + updateStatus(ThingStatus.UNKNOWN); + try { + apiClient = new RemehaApiClient(); + } catch (RuntimeException e) { + logger.debug("Failed to create API client", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "API client initialization failed"); + return; + } + + scheduler.execute(() -> authenticateAndStart(email, password, refreshInterval)); + } catch (Exception e) { + logger.debug("Initialization failed", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Configuration error: " + e.getMessage()); + } + } + + 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, "Authentication failed"); + } + } catch (Exception e) { + logger.debug("Authentication error", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "Authentication error: " + e.getMessage()); + } + } + + /** + * Cleans up resources when the handler is disposed. + * Stops the refresh job and closes the API client connection. + */ + @Override + public void dispose() { + stopRefreshJob(); + RemehaApiClient client = apiClient; + apiClient = null; + if (client != null) { + try { + client.close(); + } catch (Exception e) { + logger.debug("Error closing API 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() { + if (refreshJob != null) { + refreshJob.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, "Failed to get data"); + 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 (Exception e) { + logger.debug("Error updating data", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_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 (Exception 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 (Exception 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..29e54a6a5be6e --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerFactory.java @@ -0,0 +1,73 @@ +/* + * 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.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +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.Component; + +/** + * 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); + + /** + * 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)) { + return new RemehaHeatingHandler(thing); + } + + return null; + } +} 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..61d08f45e9837 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/i18n/remehaheating.properties @@ -0,0 +1,44 @@ +# add-on + +addon.remehaheating.name = Remeha Heating Binding +addon.remehaheating.description = This binding integrates Remeha Home heating systems. + +# thing types + +thing-type.remehaheating.boiler.label = Remeha Boiler +thing-type.remehaheating.boiler.description = Remeha Home heating system boiler + +# channel types + +channel-type.remehaheating.roomTemperature.label = Room Temperature +channel-type.remehaheating.roomTemperature.description = Current room temperature + +channel-type.remehaheating.targetTemperature.label = Target Temperature +channel-type.remehaheating.targetTemperature.description = Target room temperature + +channel-type.remehaheating.dhwTemperature.label = Hot Water Temperature +channel-type.remehaheating.dhwTemperature.description = Current hot water temperature + +channel-type.remehaheating.dhwTarget.label = Hot Water Target +channel-type.remehaheating.dhwTarget.description = Target hot water temperature + +channel-type.remehaheating.waterPressure.label = Water Pressure +channel-type.remehaheating.waterPressure.description = System water pressure + +channel-type.remehaheating.outdoorTemperature.label = Outdoor Temperature +channel-type.remehaheating.outdoorTemperature.description = Outdoor temperature + +channel-type.remehaheating.status.label = Status +channel-type.remehaheating.status.description = Boiler status + +channel-type.remehaheating.dhwMode.label = Hot Water Mode +channel-type.remehaheating.dhwMode.description = Domestic hot water zone mode + +channel-type.remehaheating.dhwStatus.label = Hot Water Status +channel-type.remehaheating.dhwStatus.description = Hot water status + +# channel-type.remehaheating.dhwBoost.label = DHW Boost +# channel-type.remehaheating.dhwBoost.description = Hot water boost mode + +channel-type.remehaheating.waterPressureOK.label = Water Pressure OK +channel-type.remehaheating.waterPressureOK.description = Water pressure status 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..0080dbb8769ea --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,141 @@ + + + + + + Remeha Home heating system boiler + heating + + + + + + + + + + + + + + + + + + email + + Remeha Home account email + + + password + + Remeha Home account password + + + + Interval to refresh data from Remeha API + 60 + + + + + + Number:Temperature + + Current room temperature + temperature + + Measurement + Temperature + + + + + + Number:Temperature + + Target room temperature + temperature + + Setpoint + Temperature + + + + + + Number:Temperature + + Current hot water temperature + + + + + Number:Temperature + + Target hot water temperature + + + + + Number:Pressure + + System water pressure + pressure + + Measurement + Pressure + + + + + + Number:Temperature + + Outdoor temperature + temperature + + Measurement + Temperature + + + + + + String + + Boiler status + + + + + String + + Domestic hot water zone mode + + + + + + + + + + + String + + Hot water status + + + + + Switch + + Water pressure 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..f38c13472f943 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaApiClientTest.java @@ -0,0 +1,87 @@ +/* + * 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 org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.http.HttpFields; +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 com.google.gson.JsonObject; + +/** + * Unit tests for {@link RemehaApiClient}. + * + * @author Michael Fraedrich - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +public class RemehaApiClientTest { + + @Mock + private HttpClientFactory httpClientFactory; + @Mock + private HttpClient httpClient; + @Mock + private Request request; + @Mock + private ContentResponse response; + @Mock + private HttpFields httpFields; + private RemehaApiClient apiClient; + + @BeforeEach + public void setUp() { + when(httpClientFactory.getCommonHttpClient()).thenReturn(httpClient); + apiClient = new RemehaApiClient(httpClientFactory); + } + + @Test + public void testConstructorWithHttpClientFactory() { + assertNotNull(apiClient); + verify(httpClientFactory).getCommonHttpClient(); + } + + @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 + } + + @Test + public void testClose() throws Exception { + // Should not throw exception + assertDoesNotThrow(() -> apiClient.close()); + } +} 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..ded68edfc7e95 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingBindingConstantsTest.java @@ -0,0 +1,59 @@ +/* + * 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.junit.jupiter.api.Test; + +/** + * Unit tests for {@link RemehaHeatingBindingConstants}. + * + * @author Michael Fraedrich - Initial contribution + */ +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("roomTemperature", RemehaHeatingBindingConstants.CHANNEL_ROOM_TEMPERATURE); + assertEquals("targetTemperature", RemehaHeatingBindingConstants.CHANNEL_TARGET_TEMPERATURE); + assertEquals("dhwTemperature", RemehaHeatingBindingConstants.CHANNEL_DHW_TEMPERATURE); + assertEquals("dhwTarget", RemehaHeatingBindingConstants.CHANNEL_DHW_TARGET); + assertEquals("waterPressure", RemehaHeatingBindingConstants.CHANNEL_WATER_PRESSURE); + assertEquals("outdoorTemperature", RemehaHeatingBindingConstants.CHANNEL_OUTDOOR_TEMPERATURE); + assertEquals("status", RemehaHeatingBindingConstants.CHANNEL_STATUS); + assertEquals("dhwMode", RemehaHeatingBindingConstants.CHANNEL_DHW_MODE); + assertEquals("waterPressureOK", RemehaHeatingBindingConstants.CHANNEL_WATER_PRESSURE_OK); + assertEquals("dhwStatus", 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..0eb57739e9ddc --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingConfigurationTest.java @@ -0,0 +1,47 @@ +/* + * 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.junit.jupiter.api.Test; + +/** + * Unit tests for {@link RemehaHeatingConfiguration}. + * + * @author Michael Fraedrich - Initial contribution + */ +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..fa93818fb0899 --- /dev/null +++ b/bundles/org.openhab.binding.remehaheating/src/test/java/org/openhab/binding/remehaheating/internal/RemehaHeatingHandlerFactoryTest.java @@ -0,0 +1,72 @@ +/* + * 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 static org.mockito.Mockito.lenient; + +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.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandler; + +/** + * Unit tests for {@link RemehaHeatingHandlerFactory}. + * + * @author Michael Fraedrich - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +public class RemehaHeatingHandlerFactoryTest { + + @Mock + private Thing thing; + private RemehaHeatingHandlerFactory factory; + private ThingUID thingUID; + + @BeforeEach + public void setUp() { + factory = new RemehaHeatingHandlerFactory(); + thingUID = new ThingUID(RemehaHeatingBindingConstants.THING_TYPE_BOILER, "test"); + lenient().when(thing.getUID()).thenReturn(thingUID); + lenient().when(thing.getThingTypeUID()).thenReturn(RemehaHeatingBindingConstants.THING_TYPE_BOILER); + } + + @Test + public void testSupportsThingType() { + assertTrue(factory.supportsThingType(RemehaHeatingBindingConstants.THING_TYPE_BOILER)); + assertFalse(factory.supportsThingType(new ThingTypeUID("other", "thing"))); + } + + @Test + public void testCreateHandler() { + ThingHandler handler = factory.createHandler(thing); + + assertNotNull(handler); + assertInstanceOf(RemehaHeatingHandler.class, handler); + } + + @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..4dd1d386dee3d --- /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.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) +public class RemehaHeatingHandlerTest { + + @Mock + private Thing thing; + @Mock + private ThingHandlerCallback callback; + @Mock + private Configuration configuration; + private RemehaHeatingHandler handler; + private ThingUID thingUID; + private 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); + 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..e4184167660e4 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -1,21 +1,14 @@ - - - + 4.0.0 - org.openhab.addons org.openhab.addons.reactor 5.1.0-SNAPSHOT - org.openhab.addons.bundles org.openhab.addons.reactor.bundles pom - openHAB Add-ons :: Bundles - org.openhab.automation.groovyscripting @@ -361,6 +354,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 @@ -510,12 +504,10 @@ org.openhab.voice.watsonstt org.openhab.voice.whisperstt - target/dependency - org.lastnpe.eea @@ -571,7 +563,6 @@ provided - @@ -641,7 +632,6 @@ - biz.aQute.bnd @@ -731,7 +721,6 @@ - skipXmlValidation @@ -776,5 +765,4 @@ - From ff59db69d9194ffe942f0699db8873223bc2d3b7 Mon Sep 17 00:00:00 2001 From: Michael Fraedrich Date: Fri, 17 Oct 2025 11:41:42 +0200 Subject: [PATCH 02/11] [remehaheating] Fixing pom.xml format issues with mvn spotless:apply Signed-off-by: Michael Fraedrich --- bundles/pom.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bundles/pom.xml b/bundles/pom.xml index e4184167660e4..7a548530f2212 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -1,4 +1,6 @@ - + + 4.0.0 org.openhab.addons From fd7e987739d66855b81697db40db673416bb7203 Mon Sep 17 00:00:00 2001 From: Michael Fraedrich Date: Fri, 17 Oct 2025 15:06:17 +0200 Subject: [PATCH 03/11] Refactor channel identifiers and types in README and XML files for consistency Signed-off-by: Michael Fraedrich --- .../README.md | 36 ++++----- .../RemehaHeatingBindingConstants.java | 20 ++--- .../resources/OH-INF/thing/thing-types.xml | 77 +++++++++++-------- bundles/pom.xml | 13 +++- 4 files changed, 86 insertions(+), 60 deletions(-) diff --git a/bundles/org.openhab.binding.remehaheating/README.md b/bundles/org.openhab.binding.remehaheating/README.md index b9ffd37345f72..1f17ec2f4f3d5 100644 --- a/bundles/org.openhab.binding.remehaheating/README.md +++ b/bundles/org.openhab.binding.remehaheating/README.md @@ -56,15 +56,15 @@ The binding provides the following channels for monitoring and controlling your | Channel | Type | Read/Write | Description | |---------------------|-------------------|------------|------------------------------------------------| -| roomTemperature | Number:Temperature| Read | Current room temperature | -| targetTemperature | Number:Temperature| Read/Write | Target room temperature (5-30°C) | -| dhwTemperature | Number:Temperature| Read | Current hot water temperature | -| dhwTarget | Number:Temperature| Read | Target hot water temperature | -| dhwMode | String | Read/Write | DHW mode (anti-frost/schedule/continuous-comfort) | -| dhwStatus | String | Read | Hot water status | -| waterPressure | Number:Pressure | Read | System water pressure | -| waterPressureOK | Switch | Read | Water pressure status (ON=OK, OFF=Low) | -| outdoorTemperature | Number:Temperature| Read | Outdoor temperature | +| 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 @@ -83,19 +83,19 @@ Thing remehaheating:boiler:myboiler "Remeha Boiler" [ ```java // Temperature monitoring -Number:Temperature RoomTemp "Room Temperature [%.1f °C]" { channel="remehaheating:boiler:myboiler:roomTemperature" } -Number:Temperature TargetTemp "Target Temperature [%.1f °C]" { channel="remehaheating:boiler:myboiler:targetTemperature" } -Number:Temperature OutdoorTemp "Outdoor Temperature [%.1f °C]" { channel="remehaheating:boiler:myboiler:outdoorTemperature" } +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:dhwTemperature" } -Number:Temperature DHWTarget "Hot Water Target [%.1f °C]" { channel="remehaheating:boiler:myboiler:dhwTarget" } -String DHWMode "Hot Water Mode [%s]" { channel="remehaheating:boiler:myboiler:dhwMode" } -String DHWStatus "Hot Water Status [%s]" { channel="remehaheating:boiler:myboiler:dhwStatus" } +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:waterPressure" } -Switch WaterPressureOK "Water Pressure OK" { channel="remehaheating:boiler:myboiler:waterPressureOK" } +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" } ``` 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 index 08084514e0d3e..20966f8071591 100644 --- 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 @@ -37,27 +37,27 @@ public class RemehaHeatingBindingConstants { // Channel identifiers /** Current room temperature channel */ - public static final String CHANNEL_ROOM_TEMPERATURE = "roomTemperature"; + public static final String CHANNEL_ROOM_TEMPERATURE = "room-temperature"; /** Target room temperature channel (read/write) */ - public static final String CHANNEL_TARGET_TEMPERATURE = "targetTemperature"; + public static final String CHANNEL_TARGET_TEMPERATURE = "target-temperature"; /** Current DHW temperature channel */ - public static final String CHANNEL_DHW_TEMPERATURE = "dhwTemperature"; + public static final String CHANNEL_DHW_TEMPERATURE = "dhw-temperature"; /** Target DHW temperature channel */ - public static final String CHANNEL_DHW_TARGET = "dhwTarget"; + public static final String CHANNEL_DHW_TARGET = "dhw-target"; /** System water pressure channel */ - public static final String CHANNEL_WATER_PRESSURE = "waterPressure"; + public static final String CHANNEL_WATER_PRESSURE = "water-pressure"; /** Outdoor temperature channel */ - public static final String CHANNEL_OUTDOOR_TEMPERATURE = "outdoorTemperature"; + 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 = "dhwMode"; + public static final String CHANNEL_DHW_MODE = "dhw-mode"; /** Water pressure OK status channel */ - public static final String CHANNEL_WATER_PRESSURE_OK = "waterPressureOK"; + public static final String CHANNEL_WATER_PRESSURE_OK = "water-pressure-ok"; /** DHW status channel */ - public static final String CHANNEL_DHW_STATUS = "dhwStatus"; + public static final String CHANNEL_DHW_STATUS = "dhw-status"; /** DHW boost mode channel (currently disabled) */ - // public static final String CHANNEL_DHW_BOOST = "dhwBoost"; + // public static final String CHANNEL_DHW_BOOST = "dhw-boost"; // Configuration parameter names /** Email configuration parameter */ 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 index 0080dbb8769ea..56273610f5548 100644 --- 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 @@ -10,16 +10,15 @@ heating - - - - - - - - - - + + + + + + + + + @@ -42,8 +41,8 @@ - - Number:Temperature + + Number:Temperature Current room temperature temperature @@ -51,11 +50,11 @@ Measurement Temperature - + - - Number:Temperature + + Number:Temperature Target room temperature temperature @@ -63,25 +62,35 @@ Setpoint Temperature - + - - Number:Temperature + + Number:Temperature Current hot water temperature - + temperature + + Measurement + Temperature + + - - Number:Temperature + + Number:Temperature Target hot water temperature - + temperature + + Setpoint + Temperature + + - - Number:Pressure + + Number:Pressure System water pressure pressure @@ -89,11 +98,11 @@ Measurement Pressure - + - - Number:Temperature + + Number:Temperature Outdoor temperature temperature @@ -101,17 +110,20 @@ Measurement Temperature - + String Boiler status + + Status + - + String Domestic hot water zone mode @@ -124,17 +136,20 @@ - + String Hot water status - + Switch Water pressure status + + Status + diff --git a/bundles/pom.xml b/bundles/pom.xml index 7a548530f2212..812be33e7b703 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -1,16 +1,21 @@ + 4.0.0 + org.openhab.addons org.openhab.addons.reactor 5.1.0-SNAPSHOT + org.openhab.addons.bundles org.openhab.addons.reactor.bundles pom + openHAB Add-ons :: Bundles + org.openhab.automation.groovyscripting @@ -506,10 +511,12 @@ org.openhab.voice.watsonstt org.openhab.voice.whisperstt + target/dependency + org.lastnpe.eea @@ -565,6 +572,7 @@ provided + @@ -634,6 +642,7 @@ + biz.aQute.bnd @@ -723,6 +732,7 @@ + skipXmlValidation @@ -767,4 +777,5 @@ - + + \ No newline at end of file From 331d40c5a88383dab5e7d42a05f09ef34b09a15e Mon Sep 17 00:00:00 2001 From: Michael Fraedrich Date: Fri, 17 Oct 2025 21:14:00 +0200 Subject: [PATCH 04/11] Codechanges related to first review findings by Copilot and lsiepel@ Signed-off-by: Michael Fraedrich --- .../README.md | 6 +- .../internal/RemehaApiClient.java | 62 +++++-------------- .../RemehaHeatingBindingConstants.java | 2 - .../internal/RemehaHeatingHandler.java | 35 +++++------ .../internal/RemehaHeatingHandlerFactory.java | 14 ++++- .../OH-INF/i18n/remehaheating.properties | 39 ++++++------ .../resources/OH-INF/thing/thing-types.xml | 3 +- .../internal/RemehaApiClientTest.java | 24 +++---- .../RemehaHeatingBindingConstantsTest.java | 18 +++--- .../RemehaHeatingHandlerFactoryTest.java | 8 ++- .../internal/RemehaHeatingHandlerTest.java | 4 +- bundles/pom.xml | 2 +- 12 files changed, 93 insertions(+), 124 deletions(-) diff --git a/bundles/org.openhab.binding.remehaheating/README.md b/bundles/org.openhab.binding.remehaheating/README.md index 1f17ec2f4f3d5..8d3852c8b9e7c 100644 --- a/bundles/org.openhab.binding.remehaheating/README.md +++ b/bundles/org.openhab.binding.remehaheating/README.md @@ -20,7 +20,7 @@ This binding supports Remeha boilers that are connected to the Remeha Home cloud - `boiler`: Represents a Remeha boiler with ThingTypeUID `remehaheating:boiler` -The binding has been tested with Remeha Tzerra boilers but should work with any Remeha boiler that supports the Remeha Home cloud service. +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 @@ -41,8 +41,8 @@ These are the same credentials you use for the Remeha Home mobile app. ### `boiler` Thing Configuration -| Name | Type | Description | Default | Required | Advanced | -|-----------------|---------|------------------------------------------------|---------|----------|----------| +| 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 | diff --git a/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaApiClient.java b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaApiClient.java index e37e0fc77df7d..69370cd5f2f0a 100644 --- a/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaApiClient.java +++ b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaApiClient.java @@ -12,7 +12,6 @@ */ package org.openhab.binding.remehaheating.internal; -import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -29,7 +28,6 @@ import org.eclipse.jetty.client.util.StringContentProvider; import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.http.HttpMethod; -import org.openhab.core.io.net.http.HttpClientFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,25 +56,13 @@ public class RemehaApiClient { 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"; - public RemehaApiClient(HttpClientFactory httpClientFactory) { - this.httpClient = httpClientFactory.getCommonHttpClient(); - } - - public RemehaApiClient() { - org.eclipse.jetty.util.ssl.SslContextFactory.Client sslContextFactory = new org.eclipse.jetty.util.ssl.SslContextFactory.Client(); - sslContextFactory.setTrustAll(true); - - this.httpClient = new HttpClient(sslContextFactory); - - try { - this.httpClient.setRequestBufferSize(16384); - this.httpClient.setResponseBufferSize(16384); - this.httpClient.start(); - } catch (Exception e) { - logger.debug("Failed to start HTTP client", e); - throw new IllegalStateException("HTTP client initialization failed", e); - } + public RemehaApiClient(HttpClient httpClient) { + httpClient.setRequestBufferSize(16384); + httpClient.setResponseBufferSize(16384); + this.httpClient = httpClient; } /** @@ -149,9 +135,9 @@ public boolean authenticate(String email, String password) { return null; } try { - ContentResponse response = httpClient.newRequest("https://api.bdrthermea.net/Mobile/api/homes/dashboard") - .method(HttpMethod.GET).header("Authorization", "Bearer " + accessToken) - .header("Ocp-Apim-Subscription-Key", "df605c5470d846fc91e848b1cc653ddf").send(); + ContentResponse response = httpClient.newRequest(API_BASE_URL + "/homes/dashboard").method(HttpMethod.GET) + .header("Authorization", "Bearer " + accessToken) + .header("Ocp-Apim-Subscription-Key", SUBSCRIPTION_KEY).send(); return gson.fromJson(response.getContentAsString(), JsonObject.class); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -222,7 +208,7 @@ private String buildAuthUrl(String codeChallenge, String state) { Matcher matcher = CSRF_PATTERN.matcher(setCookieHeader); if (matcher.find()) { String token = matcher.group(1); - logger.debug("Extracted CSRF token length: {}", token.length()); + logger.debug("CSRF token extracted from cookies"); return token; } } @@ -259,8 +245,7 @@ private boolean submitCredentials(String email, String password, String csrfToke + URLEncoder.encode(email, StandardCharsets.UTF_8) + "&password=" + URLEncoder.encode(password, StandardCharsets.UTF_8); - // amazonq-ignore-next-line - logger.debug("Submitting credentials with CSRF token length: {}", csrfToken.length()); + logger.debug("Submitting credentials with CSRF token"); Request request = httpClient.newRequest(baseUrl).method(HttpMethod.POST) .param("tx", "StateProperties=" + stateProperties).param("p", "B2C_1A_RPSignUpSignInNewRoomv3.1") @@ -310,8 +295,7 @@ private boolean submitCredentials(String email, String password, String csrfToke Matcher matcher = pattern.matcher(location); if (matcher.find()) { String authCode = matcher.group(1); - logger.debug("Extracted auth code: {}...", - authCode.substring(0, Math.min(10, authCode.length()))); + logger.debug("Authorization code successfully extracted."); return authCode; } } @@ -373,10 +357,9 @@ private boolean apiRequest(String path, @Nullable String jsonData) { return false; } try { - Request request = httpClient.newRequest("https://api.bdrthermea.net/Mobile/api" + path) - .method(HttpMethod.POST).header("Authorization", "Bearer " + accessToken) - .header("Ocp-Apim-Subscription-Key", "df605c5470d846fc91e848b1cc653ddf") - .header("Content-Type", "application/json"); + Request request = httpClient.newRequest(API_BASE_URL + path).method(HttpMethod.POST) + .header("Authorization", "Bearer " + accessToken) + .header("Ocp-Apim-Subscription-Key", SUBSCRIPTION_KEY).header("Content-Type", "application/json"); if (jsonData != null) { request.content(new StringContentProvider(jsonData)); } @@ -409,19 +392,4 @@ public boolean setTemperature(String climateZoneId, double temperature) { public boolean setDhwMode(String hotWaterZoneId, String mode) { return apiRequest("/hot-water-zones/" + hotWaterZoneId + "/modes/" + mode, null); } - - /** - * Closes the HTTP client and releases resources. - * - * @throws IOException if error occurs during shutdown - */ - public void close() throws IOException { - try { - if (httpClient != null && httpClient.isStarted()) { - httpClient.stop(); - } - } catch (Exception e) { - logger.debug("Error stopping HTTP client: {}", e.getMessage()); - } - } } 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 index 20966f8071591..cb5caf71e05e1 100644 --- 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 @@ -56,8 +56,6 @@ public class RemehaHeatingBindingConstants { public static final String CHANNEL_WATER_PRESSURE_OK = "water-pressure-ok"; /** DHW status channel */ public static final String CHANNEL_DHW_STATUS = "dhw-status"; - /** DHW boost mode channel (currently disabled) */ - // public static final String CHANNEL_DHW_BOOST = "dhw-boost"; // Configuration parameter names /** Email configuration parameter */ 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 index 6288f247bee8b..a85aabf23e794 100644 --- 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 @@ -28,6 +28,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.QuantityType; @@ -67,12 +68,14 @@ 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; private @Nullable RemehaHeatingConfiguration config; - public RemehaHeatingHandler(Thing thing) { + public RemehaHeatingHandler(Thing thing, HttpClient httpClient) { super(thing); + this.httpClient = httpClient; } /** @@ -87,8 +90,15 @@ public RemehaHeatingHandler(Thing thing) { public void handleCommand(ChannelUID channelUID, Command command) { if (command instanceof RefreshType) { updateData(); - } else if (CHANNEL_TARGET_TEMPERATURE.equals(channelUID.getId()) && command instanceof DecimalType) { - setTargetTemperature(((DecimalType) command).doubleValue()); + } 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()); } @@ -112,14 +122,7 @@ public void initialize() { } updateStatus(ThingStatus.UNKNOWN); - try { - apiClient = new RemehaApiClient(); - } catch (RuntimeException e) { - logger.debug("Failed to create API client", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "API client initialization failed"); - return; - } + apiClient = new RemehaApiClient(httpClient); scheduler.execute(() -> authenticateAndStart(email, password, refreshInterval)); } catch (Exception e) { @@ -147,20 +150,12 @@ private void authenticateAndStart(String email, String password, int refreshInte /** * Cleans up resources when the handler is disposed. - * Stops the refresh job and closes the API client connection. + * Stops the refresh job. */ @Override public void dispose() { stopRefreshJob(); - RemehaApiClient client = apiClient; apiClient = null; - if (client != null) { - try { - client.close(); - } catch (Exception e) { - logger.debug("Error closing API client", e); - } - } super.dispose(); } 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 index 29e54a6a5be6e..14c4346185f6f 100644 --- 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 @@ -12,18 +12,22 @@ */ package org.openhab.binding.remehaheating.internal; -import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.*; +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. @@ -42,6 +46,12 @@ public class RemehaHeatingHandlerFactory extends BaseThingHandlerFactory { private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BOILER); + private final HttpClient httpClient; + + @Activate + public RemehaHeatingHandlerFactory(@Reference HttpClientFactory httpClientFactory) { + this.httpClient = httpClientFactory.getCommonHttpClient(); + } /** * Checks if this factory supports the given thing type. @@ -65,7 +75,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_BOILER.equals(thingTypeUID)) { - return new RemehaHeatingHandler(thing); + return new RemehaHeatingHandler(thing, httpClient); } return null; 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 index 61d08f45e9837..68b68f350883e 100644 --- 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 @@ -10,35 +10,32 @@ thing-type.remehaheating.boiler.description = Remeha Home heating system boiler # channel types -channel-type.remehaheating.roomTemperature.label = Room Temperature -channel-type.remehaheating.roomTemperature.description = Current room temperature +channel-type.remehaheating.room-temperature.label = Room Temperature +channel-type.remehaheating.room-temperature.description = Current room temperature -channel-type.remehaheating.targetTemperature.label = Target Temperature -channel-type.remehaheating.targetTemperature.description = Target room temperature +channel-type.remehaheating.target-temperature.label = Target Temperature +channel-type.remehaheating.target-temperature.description = Target room temperature -channel-type.remehaheating.dhwTemperature.label = Hot Water Temperature -channel-type.remehaheating.dhwTemperature.description = Current 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.dhwTarget.label = Hot Water Target -channel-type.remehaheating.dhwTarget.description = Target hot water temperature +channel-type.remehaheating.dhw-target.label = Hot Water Target +channel-type.remehaheating.dhw-target.description = Target hot water temperature -channel-type.remehaheating.waterPressure.label = Water Pressure -channel-type.remehaheating.waterPressure.description = System water pressure +channel-type.remehaheating.water-pressure.label = Water Pressure +channel-type.remehaheating.water-pressure.description = System water pressure -channel-type.remehaheating.outdoorTemperature.label = Outdoor Temperature -channel-type.remehaheating.outdoorTemperature.description = Outdoor temperature +channel-type.remehaheating.outdoor-temperature.label = Outdoor Temperature +channel-type.remehaheating.outdoor-temperature.description = Outdoor temperature channel-type.remehaheating.status.label = Status channel-type.remehaheating.status.description = Boiler status -channel-type.remehaheating.dhwMode.label = Hot Water Mode -channel-type.remehaheating.dhwMode.description = Domestic hot water zone mode +channel-type.remehaheating.dhw-mode.label = Hot Water Mode +channel-type.remehaheating.dhw-mode.description = Domestic hot water zone mode -channel-type.remehaheating.dhwStatus.label = Hot Water Status -channel-type.remehaheating.dhwStatus.description = Hot water status +channel-type.remehaheating.dhw-status.label = Hot Water Status +channel-type.remehaheating.dhw-status.description = Hot water status -# channel-type.remehaheating.dhwBoost.label = DHW Boost -# channel-type.remehaheating.dhwBoost.description = Hot water boost mode - -channel-type.remehaheating.waterPressureOK.label = Water Pressure OK -channel-type.remehaheating.waterPressureOK.description = Water pressure status +channel-type.remehaheating.water-pressure-ok.label = Water Pressure OK +channel-type.remehaheating.water-pressure-ok.description = Water pressure status 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 index 56273610f5548..faf329bab593f 100644 --- 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 @@ -8,6 +8,7 @@ Remeha Home heating system boiler heating + Boiler @@ -35,7 +36,7 @@ - Interval to refresh data from Remeha API + Interval to refresh data from Remeha API (from 30 to 3600 seconds) 60 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 index f38c13472f943..efd6a472324c8 100644 --- 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 @@ -12,14 +12,14 @@ */ 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.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.verify; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.api.ContentResponse; import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.http.HttpFields; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -45,20 +45,18 @@ public class RemehaApiClientTest { private Request request; @Mock private ContentResponse response; - @Mock - private HttpFields httpFields; private RemehaApiClient apiClient; @BeforeEach public void setUp() { - when(httpClientFactory.getCommonHttpClient()).thenReturn(httpClient); - apiClient = new RemehaApiClient(httpClientFactory); + apiClient = new RemehaApiClient(httpClient); } @Test - public void testConstructorWithHttpClientFactory() { + public void testConstructor() { assertNotNull(apiClient); - verify(httpClientFactory).getCommonHttpClient(); + verify(httpClient).setRequestBufferSize(16384); + verify(httpClient).setResponseBufferSize(16384); } @Test @@ -78,10 +76,4 @@ public void testSetDhwMode() { boolean result = apiClient.setDhwMode("zone456", "schedule"); assertFalse(result); // Should return false without access token } - - @Test - public void testClose() throws Exception { - // Should not throw exception - assertDoesNotThrow(() -> apiClient.close()); - } } 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 index ded68edfc7e95..3a3285daa4927 100644 --- 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 @@ -31,16 +31,16 @@ public void testThingTypeUID() { @Test public void testChannelConstants() { - assertEquals("roomTemperature", RemehaHeatingBindingConstants.CHANNEL_ROOM_TEMPERATURE); - assertEquals("targetTemperature", RemehaHeatingBindingConstants.CHANNEL_TARGET_TEMPERATURE); - assertEquals("dhwTemperature", RemehaHeatingBindingConstants.CHANNEL_DHW_TEMPERATURE); - assertEquals("dhwTarget", RemehaHeatingBindingConstants.CHANNEL_DHW_TARGET); - assertEquals("waterPressure", RemehaHeatingBindingConstants.CHANNEL_WATER_PRESSURE); - assertEquals("outdoorTemperature", RemehaHeatingBindingConstants.CHANNEL_OUTDOOR_TEMPERATURE); + 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("dhwMode", RemehaHeatingBindingConstants.CHANNEL_DHW_MODE); - assertEquals("waterPressureOK", RemehaHeatingBindingConstants.CHANNEL_WATER_PRESSURE_OK); - assertEquals("dhwStatus", RemehaHeatingBindingConstants.CHANNEL_DHW_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 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 index fa93818fb0899..09e43456bf35b 100644 --- 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 @@ -21,6 +21,7 @@ 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.ThingUID; @@ -36,12 +37,17 @@ public class RemehaHeatingHandlerFactoryTest { @Mock private Thing thing; + @Mock + private HttpClientFactory httpClientFactory; + @Mock + private org.eclipse.jetty.client.HttpClient httpClient; private RemehaHeatingHandlerFactory factory; private ThingUID thingUID; @BeforeEach public void setUp() { - factory = new RemehaHeatingHandlerFactory(); + when(httpClientFactory.getCommonHttpClient()).thenReturn(httpClient); + factory = new RemehaHeatingHandlerFactory(httpClientFactory); thingUID = new ThingUID(RemehaHeatingBindingConstants.THING_TYPE_BOILER, "test"); lenient().when(thing.getUID()).thenReturn(thingUID); lenient().when(thing.getThingTypeUID()).thenReturn(RemehaHeatingBindingConstants.THING_TYPE_BOILER); 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 index 4dd1d386dee3d..799dab0b81801 100644 --- 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 @@ -47,6 +47,8 @@ public class RemehaHeatingHandlerTest { private ThingHandlerCallback callback; @Mock private Configuration configuration; + @Mock + private org.eclipse.jetty.client.HttpClient httpClient; private RemehaHeatingHandler handler; private ThingUID thingUID; private ChannelUID channelUID; @@ -59,7 +61,7 @@ public void setUp() { lenient().when(thing.getUID()).thenReturn(thingUID); lenient().when(thing.getConfiguration()).thenReturn(configuration); - handler = new RemehaHeatingHandler(thing); + handler = new RemehaHeatingHandler(thing, httpClient); handler.setCallback(callback); } diff --git a/bundles/pom.xml b/bundles/pom.xml index 812be33e7b703..f94d8b2a334e9 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -778,4 +778,4 @@ - \ No newline at end of file + From bc497ef2af2151c5758ba6bf45a9671b1ea7d628 Mon Sep 17 00:00:00 2001 From: Michael Fraedrich Date: Wed, 29 Oct 2025 14:32:21 +0100 Subject: [PATCH 05/11] Adding missing tags in thing-types.xml Signed-off-by: Michael Fraedrich --- .../src/main/resources/OH-INF/thing/thing-types.xml | 6 ++++++ 1 file changed, 6 insertions(+) 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 index faf329bab593f..3e389971dc6ff 100644 --- 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 @@ -128,6 +128,9 @@ String Domestic hot water zone mode + + Control + @@ -141,6 +144,9 @@ String Hot water status + + Status + From a5a0f255aaff7e866200e23140bbf6465881d0ac Mon Sep 17 00:00:00 2001 From: Michael Fraedrich Date: Fri, 28 Nov 2025 15:21:58 +0100 Subject: [PATCH 06/11] Applying suggested changes from code review by @lsiepel Signed-off-by: Michael Fraedrich --- .../internal/RemehaHeatingHandler.java | 42 ++--- .../internal/RemehaHeatingHandlerFactory.java | 7 +- .../internal/{ => api}/RemehaApiClient.java | 176 +++++++----------- .../OH-INF/i18n/remehaheating.properties | 9 + .../internal/RemehaApiClientTest.java | 4 +- .../RemehaHeatingHandlerFactoryTest.java | 12 +- 6 files changed, 110 insertions(+), 140 deletions(-) rename bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/{ => api}/RemehaApiClient.java (81%) 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 index a85aabf23e794..935f5be9fc1af 100644 --- 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 @@ -12,16 +12,7 @@ */ package org.openhab.binding.remehaheating.internal; -import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_DHW_MODE; -import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_DHW_STATUS; -import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_DHW_TARGET; -import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_DHW_TEMPERATURE; -import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_OUTDOOR_TEMPERATURE; -import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_ROOM_TEMPERATURE; -import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_STATUS; -import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_TARGET_TEMPERATURE; -import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_WATER_PRESSURE; -import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.CHANNEL_WATER_PRESSURE_OK; +import static org.openhab.binding.remehaheating.internal.RemehaHeatingBindingConstants.*; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -29,6 +20,7 @@ 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; @@ -116,8 +108,9 @@ public void initialize() { String password = config.password; int refreshInterval = config.refreshInterval; - if (email.isEmpty() || password.isEmpty()) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Email and password required"); + if (email.isBlank() || password.isBlank()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.conf-error-no-credentials"); return; } @@ -125,10 +118,10 @@ public void initialize() { apiClient = new RemehaApiClient(httpClient); scheduler.execute(() -> authenticateAndStart(email, password, refreshInterval)); - } catch (Exception e) { - logger.debug("Initialization failed", e); + } catch (IllegalArgumentException e) { + logger.debug("Invalid configuration", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Configuration error: " + e.getMessage()); + "@text/offline.conf-error-invalid-config"); } } @@ -139,12 +132,13 @@ private void authenticateAndStart(String email, String password, int refreshInte updateStatus(ThingStatus.ONLINE); startRefreshJob(refreshInterval > 0 ? refreshInterval : 60); } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Authentication failed"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/offline.comm-error-authentication-failed"); } - } catch (Exception e) { + } catch (RuntimeException e) { logger.debug("Authentication error", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "Authentication error: " + e.getMessage()); + "@text/offline.comm-error-authentication-error"); } } @@ -197,7 +191,8 @@ private void updateData() { try { JsonObject dashboard = client.getDashboard(); if (dashboard == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Failed to get data"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/offline.comm-error-data-fetch-failed"); return; } @@ -244,9 +239,10 @@ private void updateData() { updateState(CHANNEL_DHW_STATUS, new StringType(dhwStatus)); } } - } catch (Exception e) { + } catch (IllegalStateException | NullPointerException e) { logger.debug("Error updating data", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Data update failed"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/offline.comm-error-data-update-failed"); } } @@ -308,7 +304,7 @@ private void setDhwMode(String mode) { } } } - } catch (Exception e) { + } catch (IllegalStateException | NullPointerException e) { logger.debug("Error getting climate zone ID: {}", e.getMessage()); } return null; @@ -338,7 +334,7 @@ private void setDhwMode(String mode) { } } } - } catch (Exception e) { + } 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 index 14c4346185f6f..b1e1692ba73f0 100644 --- 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 @@ -46,11 +46,11 @@ public class RemehaHeatingHandlerFactory extends BaseThingHandlerFactory { private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BOILER); - private final HttpClient httpClient; + private final HttpClientFactory httpClientFactory; @Activate public RemehaHeatingHandlerFactory(@Reference HttpClientFactory httpClientFactory) { - this.httpClient = httpClientFactory.getCommonHttpClient(); + this.httpClientFactory = httpClientFactory; } /** @@ -75,6 +75,9 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_BOILER.equals(thingTypeUID)) { + HttpClient httpClient = httpClientFactory.createHttpClient("remehaheating"); + httpClient.setRequestBufferSize(16384); + httpClient.setResponseBufferSize(16384); return new RemehaHeatingHandler(thing, httpClient); } diff --git a/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaApiClient.java b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/api/RemehaApiClient.java similarity index 81% rename from bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaApiClient.java rename to bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/api/RemehaApiClient.java index 69370cd5f2f0a..ad12666145546 100644 --- a/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/RemehaApiClient.java +++ b/bundles/org.openhab.binding.remehaheating/src/main/java/org/openhab/binding/remehaheating/internal/api/RemehaApiClient.java @@ -10,13 +10,14 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.remehaheating.internal; +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; @@ -36,38 +37,50 @@ /** * The {@link RemehaApiClient} handles OAuth2 PKCE authentication and API communication with Remeha Home services. - * - * This client implements the complete OAuth2 PKCE (Proof Key for Code Exchange) authentication flow - * required by the Remeha API, including: - * - CSRF token extraction from authentication pages - * - State properties handling for Azure B2C - * - Authorization code exchange for access tokens - * - Authenticated API requests for heating system control - * - * The authentication flow matches the official Remeha mobile app implementation. + * + * 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 HttpClient httpClient; + 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) { - httpClient.setRequestBufferSize(16384); - httpClient.setResponseBufferSize(16384); 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 @@ -75,7 +88,7 @@ public RemehaApiClient(HttpClient httpClient) { * 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 @@ -87,7 +100,8 @@ public boolean authenticate(String email, String password) { String state = generateRandomString(); String authUrl = buildAuthUrl(codeChallenge, state); - Request authRequest = httpClient.newRequest(authUrl).method(HttpMethod.GET); + 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"); @@ -121,13 +135,13 @@ public boolean authenticate(String email, String password) { /** * 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() { @@ -136,8 +150,13 @@ public boolean authenticate(String email, String password) { } try { ContentResponse response = httpClient.newRequest(API_BASE_URL + "/homes/dashboard").method(HttpMethod.GET) - .header("Authorization", "Bearer " + accessToken) + .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(); @@ -150,36 +169,40 @@ public boolean authenticate(String email, String password) { } /** - * Generates SHA256-based code challenge for PKCE flow. - * - * @param verifier Code verifier string - * @return Base64-encoded SHA256 hash of verifier - * @throws Exception if SHA256 algorithm not available + * 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); } - /** - * Generates a cryptographically secure random string for PKCE parameters. - * - * @return Base64-encoded random string - */ private String generateRandomString() { byte[] bytes = new byte[32]; SECURE_RANDOM.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } - /** - * Builds the OAuth2 authorization URL with all required parameters. - * - * @param codeChallenge PKCE code challenge - * @param state OAuth2 state parameter - * @return Complete authorization URL - */ 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=" @@ -192,14 +215,6 @@ private String buildAuthUrl(String codeChallenge, String state) { + "&prompt=login" + "&signUp=False"; } - /** - * Extracts CSRF token from Set-Cookie headers in authentication response. - * - * @param response HTTP response from authorization endpoint - * @return CSRF token or null if not found - */ - private static final Pattern CSRF_PATTERN = Pattern.compile("x-ms-cpim-csrf=([^;]+)"); - private @Nullable String extractCsrfToken(ContentResponse response) { HttpFields headers = response.getHeaders(); logger.debug("Extracting CSRF token from cookies"); @@ -217,26 +232,11 @@ private String buildAuthUrl(String codeChallenge, String state) { return null; } - /** - * Creates Base64-encoded state properties for Azure B2C authentication. - * - * @param requestId Request ID from authentication response - * @return Base64-encoded JSON state properties - */ private String createStateProperties(String requestId) { String json = "{\"TID\":\"" + requestId + "\"}"; return Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8)); } - /** - * Submits user credentials to the authentication endpoint. - * - * @param email User email address - * @param password User password - * @param csrfToken CSRF token from previous request - * @param stateProperties Base64-encoded state properties - * @return true if credentials accepted, false otherwise - */ 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"; @@ -248,6 +248,7 @@ private boolean submitCredentials(String email, String password, String csrfToke 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)); @@ -269,18 +270,12 @@ private boolean submitCredentials(String email, String password, String csrfToke } } - /** - * Retrieves authorization code from authentication redirect. - * - * @param csrfToken CSRF token from authentication flow - * @param stateProperties Base64-encoded state properties - * @return Authorization code or null if not found - */ 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).param("rememberMe", "false") + 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); @@ -308,12 +303,6 @@ private boolean submitCredentials(String email, String password, String csrfToke return null; } - /** - * Exchanges authorization code for access token. - * - * @param authCode Authorization code from redirect - * @return true if token exchange successful, false otherwise - */ private boolean exchangeCodeForToken(String authCode) { try { String url = "https://remehalogin.bdrthermea.net/bdrb2cprod.onmicrosoft.com/oauth2/v2.0/token?p=B2C_1A_RPSignUpSignInNewRoomV3.1"; @@ -322,6 +311,7 @@ private boolean exchangeCodeForToken(String authCode) { + "&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)); @@ -345,51 +335,27 @@ private boolean exchangeCodeForToken(String authCode) { return false; } - /** - * Makes an authenticated API request to the Remeha service. - * - * @param path API endpoint path (e.g., "/climate-zones/123/modes/manual") - * @param jsonData JSON payload for POST requests, null for requests without body - * @return true if request successful (HTTP 200), false otherwise - */ 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) - .header("Authorization", "Bearer " + accessToken) + .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)); } - return request.send().getStatus() == 200; + 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; } } - - /** - * 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); - } } 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 index 68b68f350883e..5e0fb3dd912dd 100644 --- 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 @@ -39,3 +39,12 @@ channel-type.remehaheating.dhw-status.description = Hot water status channel-type.remehaheating.water-pressure-ok.label = Water Pressure OK channel-type.remehaheating.water-pressure-ok.description = Water pressure status + +# 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/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 index efd6a472324c8..b924ba69133b0 100644 --- 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 @@ -15,7 +15,6 @@ 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 static org.mockito.Mockito.verify; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.api.ContentResponse; @@ -25,6 +24,7 @@ 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; @@ -55,8 +55,6 @@ public void setUp() { @Test public void testConstructor() { assertNotNull(apiClient); - verify(httpClient).setRequestBufferSize(16384); - verify(httpClient).setResponseBufferSize(16384); } @Test 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 index 09e43456bf35b..66e3567b21019 100644 --- 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 @@ -14,7 +14,6 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -import static org.mockito.Mockito.lenient; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,7 +23,6 @@ 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.ThingUID; import org.openhab.core.thing.binding.ThingHandler; /** @@ -42,15 +40,11 @@ public class RemehaHeatingHandlerFactoryTest { @Mock private org.eclipse.jetty.client.HttpClient httpClient; private RemehaHeatingHandlerFactory factory; - private ThingUID thingUID; @BeforeEach public void setUp() { - when(httpClientFactory.getCommonHttpClient()).thenReturn(httpClient); + lenient().when(httpClientFactory.createHttpClient("remehaheating")).thenReturn(httpClient); factory = new RemehaHeatingHandlerFactory(httpClientFactory); - thingUID = new ThingUID(RemehaHeatingBindingConstants.THING_TYPE_BOILER, "test"); - lenient().when(thing.getUID()).thenReturn(thingUID); - lenient().when(thing.getThingTypeUID()).thenReturn(RemehaHeatingBindingConstants.THING_TYPE_BOILER); } @Test @@ -61,10 +55,14 @@ public void testSupportsThingType() { @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 From 076fb1ce98d7963331a776bce88e29c18f083cf1 Mon Sep 17 00:00:00 2001 From: Michael Fraedrich Date: Fri, 28 Nov 2025 15:22:20 +0100 Subject: [PATCH 07/11] Applying suggested changes from code review by @lsiepel Signed-off-by: Michael Fraedrich --- .../remehaheating/internal/RemehaHeatingHandler.java | 7 +++++++ .../internal/RemehaHeatingHandlerFactory.java | 5 +++++ 2 files changed, 12 insertions(+) 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 index 935f5be9fc1af..685ce2a947433 100644 --- 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 @@ -150,6 +150,13 @@ private void authenticateAndStart(String email, String password, int refreshInte public void dispose() { stopRefreshJob(); apiClient = null; + try { + if (httpClient.isStarted()) { + httpClient.stop(); + } + } catch (Exception e) { + logger.debug("Error stopping HTTP client", e); + } super.dispose(); } 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 index b1e1692ba73f0..0f845e3f259a8 100644 --- 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 @@ -78,6 +78,11 @@ public boolean supportsThingType(ThingTypeUID 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); } From a62d326cf9ba861c6d0848c178047bfbe8d51c38 Mon Sep 17 00:00:00 2001 From: Michael Fraedrich Date: Fri, 28 Nov 2025 15:40:49 +0100 Subject: [PATCH 08/11] Adding Missing Timeout and detailled exception handling in Client Signed-off-by: Michael Fraedrich --- .../binding/remehaheating/internal/api/RemehaApiClient.java | 5 +++++ 1 file changed, 5 insertions(+) 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 index ad12666145546..87387ac277007 100644 --- 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 @@ -329,6 +329,11 @@ private boolean exchangeCodeForToken(String authCode) { } 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()); } From 1bbadc0ee3f4711f53dbf819ea322b57e7e331c6 Mon Sep 17 00:00:00 2001 From: Michael Fraedrich Date: Mon, 1 Dec 2025 09:47:23 +0100 Subject: [PATCH 09/11] Fixing build warnings and SAT findings Signed-off-by: Michael Fraedrich --- .../internal/RemehaHeatingHandler.java | 8 ++++---- .../internal/RemehaApiClientTest.java | 16 +++++++-------- .../RemehaHeatingBindingConstantsTest.java | 2 ++ .../RemehaHeatingConfigurationTest.java | 2 ++ .../RemehaHeatingHandlerFactoryTest.java | 13 ++++++------ .../internal/RemehaHeatingHandlerTest.java | 20 +++++++++---------- 6 files changed, 30 insertions(+), 31 deletions(-) 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 index 685ce2a947433..da962e0ba5be2 100644 --- 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 @@ -63,7 +63,6 @@ public class RemehaHeatingHandler extends BaseThingHandler { private final HttpClient httpClient; private @Nullable RemehaApiClient apiClient; private @Nullable ScheduledFuture refreshJob; - private @Nullable RemehaHeatingConfiguration config; public RemehaHeatingHandler(Thing thing, HttpClient httpClient) { super(thing); @@ -103,7 +102,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { @Override public void initialize() { try { - config = getConfigAs(RemehaHeatingConfiguration.class); + RemehaHeatingConfiguration config = getConfigAs(RemehaHeatingConfiguration.class); String email = config.email; String password = config.password; int refreshInterval = config.refreshInterval; @@ -173,8 +172,9 @@ private void startRefreshJob(int intervalSeconds) { * Stops the periodic data refresh job if running. */ private void stopRefreshJob() { - if (refreshJob != null) { - refreshJob.cancel(true); + ScheduledFuture job = refreshJob; + if (job != null) { + job.cancel(true); refreshJob = null; } } 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 index b924ba69133b0..43c18a983130a 100644 --- 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 @@ -16,6 +16,7 @@ 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; @@ -35,17 +36,14 @@ * @author Michael Fraedrich - Initial contribution */ @ExtendWith(MockitoExtension.class) +@NonNullByDefault public class RemehaApiClientTest { - @Mock - private HttpClientFactory httpClientFactory; - @Mock - private HttpClient httpClient; - @Mock - private Request request; - @Mock - private ContentResponse response; - private RemehaApiClient apiClient; + 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() { 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 index 3a3285daa4927..059573da03267 100644 --- 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 @@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.*; +import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; /** @@ -21,6 +22,7 @@ * * @author Michael Fraedrich - Initial contribution */ +@NonNullByDefault public class RemehaHeatingBindingConstantsTest { @Test 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 index 0eb57739e9ddc..2fd49188f73e6 100644 --- 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 @@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.*; +import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; /** @@ -21,6 +22,7 @@ * * @author Michael Fraedrich - Initial contribution */ +@NonNullByDefault public class RemehaHeatingConfigurationTest { @Test 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 index 66e3567b21019..137c5bef4f7be 100644 --- 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 @@ -15,6 +15,7 @@ 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; @@ -31,15 +32,13 @@ * @author Michael Fraedrich - Initial contribution */ @ExtendWith(MockitoExtension.class) +@NonNullByDefault public class RemehaHeatingHandlerFactoryTest { - @Mock - private Thing thing; - @Mock - private HttpClientFactory httpClientFactory; - @Mock - private org.eclipse.jetty.client.HttpClient httpClient; - private RemehaHeatingHandlerFactory factory; + 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() { 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 index 799dab0b81801..0e7ec2ab7b2f7 100644 --- 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 @@ -17,6 +17,7 @@ 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; @@ -39,19 +40,16 @@ * @author Michael Fraedrich - Initial contribution */ @ExtendWith(MockitoExtension.class) +@NonNullByDefault public class RemehaHeatingHandlerTest { - @Mock - private Thing thing; - @Mock - private ThingHandlerCallback callback; - @Mock - private Configuration configuration; - @Mock - private org.eclipse.jetty.client.HttpClient httpClient; - private RemehaHeatingHandler handler; - private ThingUID thingUID; - private ChannelUID channelUID; + 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() { From d9322f48ef670299727adaac89f6dcc6d0bc36ce Mon Sep 17 00:00:00 2001 From: Michael Fraedrich Date: Mon, 1 Dec 2025 09:53:57 +0100 Subject: [PATCH 10/11] Regenerating i18n properties file Signed-off-by: Michael Fraedrich --- .../OH-INF/i18n/remehaheating.properties | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) 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 index 5e0fb3dd912dd..f5aed5d6bcfdd 100644 --- 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 @@ -1,44 +1,47 @@ # add-on addon.remehaheating.name = Remeha Heating Binding -addon.remehaheating.description = This binding integrates Remeha Home heating systems. +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 -# channel types +# thing types config -channel-type.remehaheating.room-temperature.label = Room Temperature -channel-type.remehaheating.room-temperature.description = Current room temperature +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-type.remehaheating.target-temperature.label = Target Temperature -channel-type.remehaheating.target-temperature.description = Target room temperature - -channel-type.remehaheating.dhw-temperature.label = Hot Water Temperature -channel-type.remehaheating.dhw-temperature.description = Current hot water temperature +# 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.water-pressure.label = Water Pressure -channel-type.remehaheating.water-pressure.description = System water pressure - +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.dhw-mode.label = Hot Water Mode -channel-type.remehaheating.dhw-mode.description = Domestic hot water zone mode - -channel-type.remehaheating.dhw-status.label = Hot Water Status -channel-type.remehaheating.dhw-status.description = Hot water 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 From 770f8a759b1dbd1ccc064ac0b8b01873542a683e Mon Sep 17 00:00:00 2001 From: lsiepel Date: Tue, 23 Dec 2025 13:20:29 +0100 Subject: [PATCH 11/11] Update bundles/org.openhab.binding.remehaheating/pom.xml Signed-off-by: lsiepel --- bundles/org.openhab.binding.remehaheating/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.remehaheating/pom.xml b/bundles/org.openhab.binding.remehaheating/pom.xml index 00f8dcd0f0499..c523d0d1c82a0 100644 --- a/bundles/org.openhab.binding.remehaheating/pom.xml +++ b/bundles/org.openhab.binding.remehaheating/pom.xml @@ -7,7 +7,7 @@ org.openhab.addons.bundles org.openhab.addons.reactor.bundles - 5.1.0-SNAPSHOT + 5.2.0-SNAPSHOT org.openhab.binding.remehaheating