2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.meater.internal.api;
15 import java.util.List;
17 import java.util.concurrent.ExecutionException;
18 import java.util.concurrent.TimeUnit;
19 import java.util.concurrent.TimeoutException;
21 import org.eclipse.jdt.annotation.NonNullByDefault;
22 import org.eclipse.jdt.annotation.Nullable;
23 import org.eclipse.jetty.client.HttpClient;
24 import org.eclipse.jetty.client.HttpResponseException;
25 import org.eclipse.jetty.client.api.ContentResponse;
26 import org.eclipse.jetty.client.api.Request;
27 import org.eclipse.jetty.client.api.Response;
28 import org.eclipse.jetty.client.util.StringContentProvider;
29 import org.eclipse.jetty.http.HttpHeader;
30 import org.eclipse.jetty.http.HttpMethod;
31 import org.eclipse.jetty.http.HttpStatus;
32 import org.openhab.binding.meater.internal.MeaterBridgeConfiguration;
33 import org.openhab.binding.meater.internal.dto.MeaterProbeDTO;
34 import org.openhab.binding.meater.internal.dto.MeaterProbeDTO.Device;
35 import org.openhab.binding.meater.internal.exceptions.MeaterAuthenticationException;
36 import org.openhab.binding.meater.internal.exceptions.MeaterException;
37 import org.openhab.core.i18n.LocaleProvider;
38 import org.slf4j.Logger;
39 import org.slf4j.LoggerFactory;
41 import com.google.gson.Gson;
42 import com.google.gson.JsonElement;
43 import com.google.gson.JsonObject;
44 import com.google.gson.JsonParseException;
45 import com.google.gson.JsonParser;
46 import com.google.gson.JsonSyntaxException;
49 * The {@link MeaterRestAPI} class defines the MEATER REST API
51 * @author Jan Gustafsson - Initial contribution
54 public class MeaterRestAPI {
55 private static final String API_ENDPOINT = "https://public-api.cloud.meater.com/v1/";
56 private static final String JSON_CONTENT_TYPE = "application/json";
57 private static final String LOGIN = "login";
58 private static final String DEVICES = "devices";
59 private static final int MAX_RETRIES = 3;
60 private static final int REQUEST_TIMEOUT_MS = 10_000;
62 private final Logger logger = LoggerFactory.getLogger(MeaterRestAPI.class);
63 private final Gson gson;
64 private final HttpClient httpClient;
65 private final MeaterBridgeConfiguration configuration;
66 private String authToken = "";
67 private LocaleProvider localeProvider;
69 public MeaterRestAPI(MeaterBridgeConfiguration configuration, Gson gson, HttpClient httpClient,
70 LocaleProvider localeProvider) {
72 this.configuration = configuration;
73 this.httpClient = httpClient;
74 this.localeProvider = localeProvider;
77 public boolean refresh(Map<String, MeaterProbeDTO.Device> meaterProbeThings) {
79 MeaterProbeDTO dto = getDevices(MeaterProbeDTO.class);
81 List<Device> devices = dto.getData().getDevices();
82 if (devices != null) {
83 if (!devices.isEmpty()) {
84 for (Device meaterProbe : devices) {
85 meaterProbeThings.put(meaterProbe.id, meaterProbe);
88 meaterProbeThings.clear();
93 } catch (MeaterException e) {
94 logger.warn("Failed to refresh! {}", e.getMessage());
99 private void login() throws MeaterException {
102 String json = "{ \"email\": \"" + configuration.email + "\", \"password\": \"" + configuration.password
104 Request request = httpClient.newRequest(API_ENDPOINT + LOGIN).method(HttpMethod.POST)
105 .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
106 request.header(HttpHeader.ACCEPT, JSON_CONTENT_TYPE);
107 request.header(HttpHeader.CONTENT_TYPE, JSON_CONTENT_TYPE);
108 request.content(new StringContentProvider(json), JSON_CONTENT_TYPE);
110 logger.trace("{}.", request.toString());
112 ContentResponse httpResponse = request.send();
113 if (!HttpStatus.isSuccess(httpResponse.getStatus())) {
114 throw new MeaterException("Failed to login " + httpResponse.getContentAsString());
117 json = httpResponse.getContentAsString();
118 JsonObject jsonObject = JsonParser.parseString(json).getAsJsonObject();
119 JsonObject childObject = jsonObject.getAsJsonObject("data");
120 JsonElement tokenJson = childObject.get("token");
121 if (tokenJson != null) {
122 this.authToken = tokenJson.getAsString();
124 throw new MeaterException("Token is not present in the JSON response");
126 } catch (TimeoutException | ExecutionException | JsonParseException e) {
127 throw new MeaterException(e);
128 } catch (InterruptedException e) {
129 Thread.currentThread().interrupt();
130 throw new MeaterException(e);
134 private String getFromApi(String uri) throws MeaterException {
136 for (int i = 0; i < MAX_RETRIES; i++) {
138 Request request = httpClient.newRequest(API_ENDPOINT + uri).method(HttpMethod.GET)
139 .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
140 request.header(HttpHeader.AUTHORIZATION, "Bearer " + authToken);
141 request.header(HttpHeader.ACCEPT, JSON_CONTENT_TYPE);
142 request.header(HttpHeader.CONTENT_TYPE, JSON_CONTENT_TYPE);
143 request.header(HttpHeader.ACCEPT_LANGUAGE, localeProvider.getLocale().getLanguage());
145 ContentResponse response = request.send();
146 String content = response.getContentAsString();
147 logger.trace("API response: {}", content);
149 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
150 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
151 logger.debug("getFromApi failed, authentication failed, HTTP status: 401");
152 throw new MeaterAuthenticationException("Authentication failed");
153 } else if (!HttpStatus.isSuccess(response.getStatus())) {
154 logger.debug("getFromApi failed, HTTP status: {}", response.getStatus());
155 throw new MeaterException("Failed to fetch from API!");
159 } catch (TimeoutException e) {
160 logger.debug("TimeoutException error in get: {}", e.getMessage());
163 throw new MeaterException("Failed to fetch from API!");
164 } catch (ExecutionException e) {
165 Throwable cause = e.getCause();
166 if (cause instanceof HttpResponseException httpResponseException) {
167 Response response = httpResponseException.getResponse();
168 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
170 * When contextId is not valid, the service will respond with HTTP code 401 without
171 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
172 * HttpResponseException. We need to handle this in order to attempt
175 logger.debug("getFromApi failed, authentication failed, HTTP status: 401");
176 throw new MeaterAuthenticationException("Authentication failed");
179 throw new MeaterException(e);
180 } catch (InterruptedException e) {
181 Thread.currentThread().interrupt();
182 throw new MeaterException(e);
186 public @Nullable <T> T getDevices(Class<T> dto) throws MeaterException {
187 String uri = DEVICES;
190 if (authToken.isEmpty()) {
195 json = getFromApi(uri);
196 } catch (MeaterAuthenticationException e) {
197 logger.debug("getFromApi failed {}", e.getMessage());
200 json = getFromApi(uri);
203 if (json.isEmpty()) {
204 throw new MeaterException("JSON from API is empty!");
207 return gson.fromJson(json, dto);
208 } catch (JsonSyntaxException e) {
209 throw new MeaterException("Error parsing JSON", e);