From 8f77e16e18a8b0a843ec3eead441799b32d03136 Mon Sep 17 00:00:00 2001
From: Florian Hotze <florianh_dev@icloud.com>
Date: Sun, 19 Jan 2025 17:05:38 +0100
Subject: [PATCH] [fronius] Fix invalid credentials lead to unexpected
 exception (#18130)

With the changes from this PR, the status code is now properly read and for 401, a meaningful warnings is logged instead.

Signed-off-by: Florian Hotze <dev@florianhotze.com>
---
 .../internal/api/FroniusBatteryControl.java   | 24 ++++--
 .../internal/api/FroniusConfigAuthUtil.java   | 85 +++++++++++++++----
 .../api/FroniusUnauthorizedException.java     | 24 ++++++
 .../handler/FroniusSymoInverterHandler.java   | 13 +++
 4 files changed, 121 insertions(+), 25 deletions(-)
 create mode 100644 bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusUnauthorizedException.java

diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusBatteryControl.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusBatteryControl.java
index 7eb4bef03a8..4eb2cc80658 100644
--- a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusBatteryControl.java
+++ b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusBatteryControl.java
@@ -77,8 +77,9 @@ public class FroniusBatteryControl {
      *
      * @return the time of use settings
      * @throws FroniusCommunicationException if an error occurs during communication with the inverter
+     * @throws FroniusUnauthorizedException when the login failed due to invalid credentials
      */
-    private TimeOfUseRecords getTimeOfUse() throws FroniusCommunicationException {
+    private TimeOfUseRecords getTimeOfUse() throws FroniusCommunicationException, FroniusUnauthorizedException {
         // Login and get the auth header for the next request
         String authHeader = FroniusConfigAuthUtil.login(httpClient, baseUri, username, password, HttpMethod.GET,
                 timeOfUseUri.getPath(), API_TIMEOUT);
@@ -107,8 +108,10 @@ public class FroniusBatteryControl {
      *
      * @param records the time of use settings
      * @throws FroniusCommunicationException if an error occurs during communication with the inverter
+     * @throws FroniusUnauthorizedException when the login failed due to invalid credentials
      */
-    private void setTimeOfUse(TimeOfUseRecords records) throws FroniusCommunicationException {
+    private void setTimeOfUse(TimeOfUseRecords records)
+            throws FroniusCommunicationException, FroniusUnauthorizedException {
         // Login and get the auth header for the next request
         String authHeader = FroniusConfigAuthUtil.login(httpClient, baseUri, username, password, HttpMethod.POST,
                 timeOfUseUri.getPath(), API_TIMEOUT);
@@ -127,8 +130,9 @@ public class FroniusBatteryControl {
      * inverter.
      *
      * @throws FroniusCommunicationException when an error occurs during communication with the inverter
+     * @throws FroniusUnauthorizedException when the login failed due to invalid credentials
      */
-    public void reset() throws FroniusCommunicationException {
+    public void reset() throws FroniusCommunicationException, FroniusUnauthorizedException {
         setTimeOfUse(new TimeOfUseRecords(new TimeOfUseRecord[0]));
     }
 
@@ -136,8 +140,9 @@ public class FroniusBatteryControl {
      * Holds the battery charge right now, i.e. prevents the battery from discharging.
      *
      * @throws FroniusCommunicationException when an error occurs during communication with the inverter
+     * @throws FroniusUnauthorizedException when the login failed due to invalid credentials
      */
-    public void holdBatteryCharge() throws FroniusCommunicationException {
+    public void holdBatteryCharge() throws FroniusCommunicationException, FroniusUnauthorizedException {
         reset();
         addHoldBatteryChargeSchedule(BEGIN_OF_DAY, END_OF_DAY);
     }
@@ -149,8 +154,10 @@ public class FroniusBatteryControl {
      * @param from start time of the hold charge period
      * @param until end time of the hold charge period
      * @throws FroniusCommunicationException when an error occurs during communication with the inverter
+     * @throws FroniusUnauthorizedException when the login failed due to invalid credentials
      */
-    public void addHoldBatteryChargeSchedule(LocalTime from, LocalTime until) throws FroniusCommunicationException {
+    public void addHoldBatteryChargeSchedule(LocalTime from, LocalTime until)
+            throws FroniusCommunicationException, FroniusUnauthorizedException {
         TimeOfUseRecord[] currentTimeOfUse = getTimeOfUse().records();
         TimeOfUseRecord[] timeOfUse = new TimeOfUseRecord[currentTimeOfUse.length + 1];
         System.arraycopy(currentTimeOfUse, 0, timeOfUse, 0, currentTimeOfUse.length);
@@ -166,8 +173,10 @@ public class FroniusBatteryControl {
      *
      * @param power the power to charge the battery with
      * @throws FroniusCommunicationException when an error occurs during communication with the inverter
+     * @throws FroniusUnauthorizedException when the login failed due to invalid credentials
      */
-    public void forceBatteryCharging(QuantityType<Power> power) throws FroniusCommunicationException {
+    public void forceBatteryCharging(QuantityType<Power> power)
+            throws FroniusCommunicationException, FroniusUnauthorizedException {
         reset();
         addForcedBatteryChargingSchedule(BEGIN_OF_DAY, END_OF_DAY, power);
     }
@@ -179,9 +188,10 @@ public class FroniusBatteryControl {
      * @param until end time of the forced charge period
      * @param power the power to charge the battery with
      * @throws FroniusCommunicationException when an error occurs during communication with the inverter
+     * @throws FroniusUnauthorizedException when the login failed due to invalid credentials
      */
     public void addForcedBatteryChargingSchedule(LocalTime from, LocalTime until, QuantityType<Power> power)
-            throws FroniusCommunicationException {
+            throws FroniusCommunicationException, FroniusUnauthorizedException {
         TimeOfUseRecords currentTimeOfUse = getTimeOfUse();
         TimeOfUseRecord[] timeOfUse = new TimeOfUseRecord[currentTimeOfUse.records().length + 1];
         System.arraycopy(currentTimeOfUse.records(), 0, timeOfUse, 0, currentTimeOfUse.records().length);
diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusConfigAuthUtil.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusConfigAuthUtil.java
index 918f98ccdc8..f3f3a2e5cce 100644
--- a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusConfigAuthUtil.java
+++ b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusConfigAuthUtil.java
@@ -19,14 +19,11 @@ import java.security.NoSuchAlgorithmException;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
 
 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.api.Response;
 import org.eclipse.jetty.http.HttpHeader;
@@ -69,10 +66,10 @@ public class FroniusConfigAuthUtil {
             throws IOException {
         LOGGER.debug("Sending login request to get authentication challenge");
         CountDownLatch latch = new CountDownLatch(1);
-        Request initialRequest = httpClient.newRequest(loginUri).timeout(timeout, TimeUnit.MILLISECONDS);
-        XWwwAuthenticateHeaderListener XWwwAuthenticateHeaderListener = new XWwwAuthenticateHeaderListener(latch);
-        initialRequest.onResponseHeaders(XWwwAuthenticateHeaderListener);
-        initialRequest.send(result -> latch.countDown());
+        Request request = httpClient.newRequest(loginUri).timeout(timeout, TimeUnit.MILLISECONDS);
+        XWwwAuthenticateHeaderListener xWwwAuthenticateHeaderListener = new XWwwAuthenticateHeaderListener(latch);
+        request.onResponseHeaders(xWwwAuthenticateHeaderListener);
+        request.send(result -> latch.countDown());
         // Wait for the request to complete
         try {
             latch.await();
@@ -80,7 +77,7 @@ public class FroniusConfigAuthUtil {
             throw new RuntimeException(ie);
         }
 
-        String authHeader = XWwwAuthenticateHeaderListener.getAuthHeader();
+        String authHeader = xWwwAuthenticateHeaderListener.getAuthHeader();
         if (authHeader == null) {
             throw new IOException("No authentication header found in login response");
         }
@@ -161,21 +158,40 @@ public class FroniusConfigAuthUtil {
      * @param authHeader the authentication header to use for the login request
      * @throws InterruptedException when the request is interrupted
      * @throws FroniusCommunicationException when the login request failed
+     * @throws FroniusUnauthorizedException when the login failed due to invalid credentials
      */
     private static void performLoginRequest(HttpClient httpClient, URI loginUri, String authHeader, int timeout)
-            throws InterruptedException, FroniusCommunicationException {
-        Request loginRequest = httpClient.newRequest(loginUri).header(HttpHeader.AUTHORIZATION, authHeader)
-                .timeout(timeout, TimeUnit.MILLISECONDS);
-        ContentResponse loginResponse;
+            throws InterruptedException, FroniusCommunicationException, FroniusUnauthorizedException {
+        CountDownLatch latch = new CountDownLatch(1);
+        Request request = httpClient.newRequest(loginUri).header(HttpHeader.AUTHORIZATION, authHeader).timeout(timeout,
+                TimeUnit.MILLISECONDS);
+        StatusListener statusListener = new StatusListener(latch);
+        request.onResponseBegin(statusListener);
+        Integer status;
         try {
-            loginResponse = loginRequest.send();
-            if (loginResponse.getStatus() != 200) {
-                throw new FroniusCommunicationException(
-                        "Failed to send login request, status code: " + loginResponse.getStatus());
+            request.send(result -> latch.countDown());
+            // Wait for the request to complete
+            try {
+                latch.await();
+            } catch (InterruptedException ie) {
+                throw new RuntimeException(ie);
             }
-        } catch (TimeoutException | ExecutionException e) {
+
+            status = statusListener.getStatus();
+            if (status == null) {
+                throw new FroniusCommunicationException("Failed to send login request: No status code received.");
+            }
+        } catch (IOException e) {
             throw new FroniusCommunicationException("Failed to send login request", e);
         }
+
+        if (status == 401) {
+            throw new FroniusUnauthorizedException(
+                    "Failed to send login request, status code: 401 Unauthorized. Please check your credentials.");
+        }
+        if (status != 200) {
+            throw new FroniusCommunicationException("Failed to send login request, status code: " + status);
+        }
     }
 
     /**
@@ -191,9 +207,11 @@ public class FroniusConfigAuthUtil {
      * @param timeout the timeout in milliseconds for the login requests
      * @return the authentication header for the next request
      * @throws FroniusCommunicationException when the login failed or interrupted
+     * @throws FroniusUnauthorizedException when the login failed due to invalid credentials
      */
     public static synchronized String login(HttpClient httpClient, URI baseUri, String username, String password,
-            HttpMethod method, String relativeUrl, int timeout) throws FroniusCommunicationException {
+            HttpMethod method, String relativeUrl, int timeout)
+            throws FroniusCommunicationException, FroniusUnauthorizedException {
         // Perform request to get authentication parameters
         LOGGER.debug("Getting authentication parameters");
         URI loginUri = baseUri.resolve(URI.create(LOGIN_ENDPOINT + "?user=" + username));
@@ -246,6 +264,8 @@ public class FroniusConfigAuthUtil {
                     Thread.sleep(500 * attemptCount);
                     attemptCount++;
                     lastException = e;
+                } catch (FroniusUnauthorizedException e) {
+                    throw e;
                 }
 
                 if (attemptCount >= 3) {
@@ -269,6 +289,9 @@ public class FroniusConfigAuthUtil {
 
     /**
      * Listener to extract the X-Www-Authenticate header from the response of a {@link Request}.
+     * Required to mitigate {@link org.eclipse.jetty.client.HttpResponseException}: HTTP protocol violation:
+     * Authentication challenge without WWW-Authenticate header being thrown due to Fronius non-standard authentication
+     * header.
      */
     private static class XWwwAuthenticateHeaderListener extends Response.Listener.Adapter {
         private final CountDownLatch latch;
@@ -288,4 +311,30 @@ public class FroniusConfigAuthUtil {
             return authHeader;
         }
     }
+
+    /**
+     * Listener to extract the HTTP status code from the response of a {@link Request} on response begin.
+     * Required to mitigate {@link org.eclipse.jetty.client.HttpResponseException}: HTTP protocol violation:
+     * Authentication challenge without WWW-Authenticate header being thrown due to Fronius non-standard authentication
+     * header.
+     */
+    private static class StatusListener extends Response.Listener.Adapter {
+        private final CountDownLatch latch;
+        private @Nullable Integer status;
+
+        public StatusListener(CountDownLatch latch) {
+            this.latch = latch;
+        }
+
+        @Override
+        public void onBegin(Response response) {
+            this.status = response.getStatus();
+            latch.countDown();
+            super.onBegin(response);
+        }
+
+        public @Nullable Integer getStatus() {
+            return status;
+        }
+    }
 }
diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusUnauthorizedException.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusUnauthorizedException.java
new file mode 100644
index 00000000000..497be1b5b2d
--- /dev/null
+++ b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusUnauthorizedException.java
@@ -0,0 +1,24 @@
+/*
+ * 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.fronius.internal.api;
+
+/**
+ * Exception for 401 response from the Fronius controller.
+ *
+ * @author Florian Hotze - Initial contribution
+ */
+public class FroniusUnauthorizedException extends Exception {
+    public FroniusUnauthorizedException(String message) {
+        super(message);
+    }
+}
diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/handler/FroniusSymoInverterHandler.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/handler/FroniusSymoInverterHandler.java
index cd2eb54c34f..c568619333c 100644
--- a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/handler/FroniusSymoInverterHandler.java
+++ b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/handler/FroniusSymoInverterHandler.java
@@ -29,6 +29,7 @@ import org.openhab.binding.fronius.internal.FroniusBridgeConfiguration;
 import org.openhab.binding.fronius.internal.action.FroniusSymoInverterActions;
 import org.openhab.binding.fronius.internal.api.FroniusBatteryControl;
 import org.openhab.binding.fronius.internal.api.FroniusCommunicationException;
+import org.openhab.binding.fronius.internal.api.FroniusUnauthorizedException;
 import org.openhab.binding.fronius.internal.api.dto.ValueUnit;
 import org.openhab.binding.fronius.internal.api.dto.inverter.InverterDeviceStatus;
 import org.openhab.binding.fronius.internal.api.dto.inverter.InverterRealtimeBody;
@@ -115,6 +116,8 @@ public class FroniusSymoInverterHandler extends FroniusBaseThingHandler {
                 return true;
             } catch (FroniusCommunicationException e) {
                 logger.warn("Failed to reset battery control", e);
+            } catch (FroniusUnauthorizedException e) {
+                logger.warn("Failed to reset battery control: Invalid username or password");
             }
         }
         return false;
@@ -128,6 +131,8 @@ public class FroniusSymoInverterHandler extends FroniusBaseThingHandler {
                 return true;
             } catch (FroniusCommunicationException e) {
                 logger.warn("Failed to set battery control to hold battery charge", e);
+            } catch (FroniusUnauthorizedException e) {
+                logger.warn("Failed to set battery control to hold battery charge: Invalid username or password");
             }
         }
         return false;
@@ -141,6 +146,9 @@ public class FroniusSymoInverterHandler extends FroniusBaseThingHandler {
                 return true;
             } catch (FroniusCommunicationException e) {
                 logger.warn("Failed to add hold battery charge schedule to battery control", e);
+            } catch (FroniusUnauthorizedException e) {
+                logger.warn(
+                        "Failed to add hold battery charge schedule to battery control: Invalid username or password");
             }
         }
         return false;
@@ -154,6 +162,8 @@ public class FroniusSymoInverterHandler extends FroniusBaseThingHandler {
                 return true;
             } catch (FroniusCommunicationException e) {
                 logger.warn("Failed to set battery control to force battery charge", e);
+            } catch (FroniusUnauthorizedException e) {
+                logger.warn("Failed to set battery control to force battery charge: Invalid username or password");
             }
         }
         return false;
@@ -167,6 +177,9 @@ public class FroniusSymoInverterHandler extends FroniusBaseThingHandler {
                 return true;
             } catch (FroniusCommunicationException e) {
                 logger.warn("Failed to add forced battery charge schedule to battery control", e);
+            } catch (FroniusUnauthorizedException e) {
+                logger.warn(
+                        "Failed to add forced battery charge schedule to battery control: Invalid username or password");
             }
         }
         return false;