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.tibber.internal.handler;
15 import static org.openhab.binding.tibber.internal.TibberBindingConstants.*;
17 import java.io.IOException;
18 import java.io.InputStream;
19 import java.math.BigDecimal;
21 import java.net.URISyntaxException;
22 import java.util.Properties;
23 import java.util.concurrent.Future;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.eclipse.jetty.client.HttpClient;
30 import org.eclipse.jetty.http.HttpHeader;
31 import org.eclipse.jetty.util.ssl.SslContextFactory;
32 import org.eclipse.jetty.websocket.api.Session;
33 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
34 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
35 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
36 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
37 import org.eclipse.jetty.websocket.api.annotations.WebSocket;
38 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
39 import org.eclipse.jetty.websocket.client.WebSocketClient;
40 import org.openhab.binding.tibber.internal.config.TibberConfiguration;
41 import org.openhab.core.io.net.http.HttpUtil;
42 import org.openhab.core.library.types.DateTimeType;
43 import org.openhab.core.library.types.DecimalType;
44 import org.openhab.core.library.types.QuantityType;
45 import org.openhab.core.library.types.StringType;
46 import org.openhab.core.library.unit.Units;
47 import org.openhab.core.thing.ChannelUID;
48 import org.openhab.core.thing.Thing;
49 import org.openhab.core.thing.ThingStatus;
50 import org.openhab.core.thing.ThingStatusDetail;
51 import org.openhab.core.thing.ThingStatusInfo;
52 import org.openhab.core.thing.binding.BaseThingHandler;
53 import org.openhab.core.types.Command;
54 import org.openhab.core.types.RefreshType;
55 import org.osgi.framework.FrameworkUtil;
56 import org.slf4j.Logger;
57 import org.slf4j.LoggerFactory;
59 import com.google.gson.JsonArray;
60 import com.google.gson.JsonElement;
61 import com.google.gson.JsonObject;
62 import com.google.gson.JsonParser;
63 import com.google.gson.JsonSyntaxException;
66 * The {@link TibberHandler} is responsible for handling queries to/from Tibber API.
68 * @author Stian Kjoglum - Initial contribution
71 public class TibberHandler extends BaseThingHandler {
72 private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(20);
73 private final Logger logger = LoggerFactory.getLogger(TibberHandler.class);
74 private final Properties httpHeader = new Properties();
75 private TibberConfiguration tibberConfig = new TibberConfiguration();
76 private @Nullable SslContextFactory sslContextFactory;
77 private @Nullable TibberWebSocketListener socket;
78 private @Nullable Session session;
79 private @Nullable WebSocketClient client;
80 private @Nullable ScheduledFuture<?> pollingJob;
81 private @Nullable Future<?> sessionFuture;
82 private String rtEnabled = "false";
83 private @Nullable String subscriptionURL;
84 private @Nullable String versionString;
86 public TibberHandler(Thing thing) {
91 public void initialize() {
92 updateStatus(ThingStatus.UNKNOWN);
93 tibberConfig = getConfigAs(TibberConfiguration.class);
95 versionString = FrameworkUtil.getBundle(this.getClass()).getVersion().toString();
96 logger.debug("Binding version: {}", versionString);
98 getTibberParameters();
99 startRefresh(tibberConfig.getRefresh());
103 public void handleCommand(ChannelUID channelUID, Command command) {
104 if (command instanceof RefreshType) {
105 startRefresh(tibberConfig.getRefresh());
107 logger.debug("Tibber API is read-only and does not handle commands");
111 public void getTibberParameters() {
112 String response = "";
114 httpHeader.put("cache-control", "no-cache");
115 httpHeader.put("content-type", JSON_CONTENT_TYPE);
116 httpHeader.put(HttpHeader.USER_AGENT.asString(),
117 "openHAB/Tibber " + versionString + " Tibber driver " + TIBBER_DRIVER);
118 httpHeader.put(HttpHeader.AUTHORIZATION.asString(), "Bearer " + tibberConfig.getToken());
120 TibberPriceConsumptionHandler tibberQuery = new TibberPriceConsumptionHandler();
121 InputStream connectionStream = tibberQuery.connectionInputStream(tibberConfig.getHomeid());
122 response = HttpUtil.executeUrl("POST", BASE_URL, httpHeader, connectionStream, null, REQUEST_TIMEOUT);
124 if (!response.contains("error") && !response.contains("<html>")) {
125 updateStatus(ThingStatus.ONLINE);
126 getURLInput(BASE_URL);
128 InputStream inputStream = tibberQuery.getRealtimeInputStream(tibberConfig.getHomeid());
129 String jsonResponse = HttpUtil.executeUrl("POST", BASE_URL, httpHeader, inputStream, null,
132 JsonObject object = (JsonObject) JsonParser.parseString(jsonResponse);
133 JsonObject dObject = object.getAsJsonObject("data");
134 if (dObject != null) {
135 JsonObject viewerObject = dObject.getAsJsonObject("viewer");
136 if (viewerObject != null) {
137 JsonObject homeObject = viewerObject.getAsJsonObject("home");
138 if (homeObject != null) {
139 JsonObject featuresObject = homeObject.getAsJsonObject("features");
140 if (featuresObject != null) {
141 rtEnabled = featuresObject.get("realTimeConsumptionEnabled").toString();
147 if ("true".equals(rtEnabled)) {
148 logger.debug("Pulse associated with HomeId: Live stream will be started");
150 InputStream wsURL = tibberQuery.getWebsocketUrl();
151 String wsResponse = HttpUtil.executeUrl("POST", BASE_URL, httpHeader, wsURL, null, REQUEST_TIMEOUT);
153 JsonObject wsobject = (JsonObject) JsonParser.parseString(wsResponse);
154 JsonObject dataObject = wsobject.getAsJsonObject("data");
155 if (dataObject != null) {
156 JsonObject viewerObject = dataObject.getAsJsonObject("viewer");
157 if (viewerObject != null) {
158 JsonElement subscriptionElement = viewerObject.get("websocketSubscriptionUrl");
159 if (subscriptionElement != null) {
160 subscriptionURL = subscriptionElement.toString().replaceAll("^\"|\"$", "");
164 String url = subscriptionURL;
165 if (url == null || url.isBlank()) {
166 logger.trace("Unexpected result from the server: {}", jsonResponse);
167 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
168 "Unexpected result from the server");
170 logger.debug("Subscribing to: {}", subscriptionURL);
174 logger.debug("No Pulse associated with HomeId: No live stream will be started");
177 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
178 "Problems connecting/communicating with server: " + response);
180 } catch (IOException | JsonSyntaxException e) {
181 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
185 public void getURLInput(String url) throws IOException {
186 String jsonResponse = "";
187 TibberPriceConsumptionHandler tibberQuery = new TibberPriceConsumptionHandler();
189 InputStream inputStream = tibberQuery.getInputStream(tibberConfig.getHomeid());
190 jsonResponse = HttpUtil.executeUrl("POST", url, httpHeader, inputStream, null, REQUEST_TIMEOUT);
191 logger.debug("API response: {}", jsonResponse);
193 if (!jsonResponse.contains("error") && !jsonResponse.contains("<html>")) {
194 if (getThing().getStatus() == ThingStatus.OFFLINE || getThing().getStatus() == ThingStatus.INITIALIZING) {
195 updateStatus(ThingStatus.ONLINE);
198 JsonObject rootJsonObject = (JsonObject) JsonParser.parseString(jsonResponse);
200 if (jsonResponse.contains("total")) {
202 JsonObject current = rootJsonObject.getAsJsonObject("data").getAsJsonObject("viewer")
203 .getAsJsonObject("home").getAsJsonObject("currentSubscription").getAsJsonObject("priceInfo")
204 .getAsJsonObject("current");
206 updateState(CURRENT_TOTAL, new DecimalType(current.get("total").toString()));
207 String timestamp = current.get("startsAt").toString().substring(1, 20);
208 updateState(CURRENT_STARTSAT, new DateTimeType(timestamp));
209 updateState(CURRENT_LEVEL,
210 new StringType(current.get("level").toString().replaceAll("^\"|\"$", "")));
212 JsonArray tomorrow = rootJsonObject.getAsJsonObject("data").getAsJsonObject("viewer")
213 .getAsJsonObject("home").getAsJsonObject("currentSubscription").getAsJsonObject("priceInfo")
214 .getAsJsonArray("tomorrow");
215 updateState(TOMORROW_PRICES, new StringType(tomorrow.toString()));
216 JsonArray today = rootJsonObject.getAsJsonObject("data").getAsJsonObject("viewer")
217 .getAsJsonObject("home").getAsJsonObject("currentSubscription").getAsJsonObject("priceInfo")
218 .getAsJsonArray("today");
219 updateState(TODAY_PRICES, new StringType(today.toString()));
220 } catch (JsonSyntaxException e) {
221 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
222 "Error communicating with Tibber API: " + e.getMessage());
225 if (jsonResponse.contains("daily") && !jsonResponse.contains("\"daily\":{\"nodes\":[]")
226 && !jsonResponse.contains("\"daily\":null")) {
228 JsonObject myObject = (JsonObject) rootJsonObject.getAsJsonObject("data").getAsJsonObject("viewer")
229 .getAsJsonObject("home").getAsJsonObject("daily").getAsJsonArray("nodes").get(0);
231 String timestampDailyFrom = myObject.get("from").toString().substring(1, 20);
232 updateState(DAILY_FROM, new DateTimeType(timestampDailyFrom));
234 String timestampDailyTo = myObject.get("to").toString().substring(1, 20);
235 updateState(DAILY_TO, new DateTimeType(timestampDailyTo));
237 updateChannel(DAILY_COST, myObject.get("cost").toString());
238 updateChannel(DAILY_CONSUMPTION, myObject.get("consumption").toString());
239 } catch (JsonSyntaxException e) {
240 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
241 "Error communicating with Tibber API: " + e.getMessage());
244 if (jsonResponse.contains("hourly") && !jsonResponse.contains("\"hourly\":{\"nodes\":[]")
245 && !jsonResponse.contains("\"hourly\":null")) {
247 JsonObject myObject = (JsonObject) rootJsonObject.getAsJsonObject("data").getAsJsonObject("viewer")
248 .getAsJsonObject("home").getAsJsonObject("hourly").getAsJsonArray("nodes").get(0);
250 String timestampHourlyFrom = myObject.get("from").toString().substring(1, 20);
251 updateState(HOURLY_FROM, new DateTimeType(timestampHourlyFrom));
253 String timestampHourlyTo = myObject.get("to").toString().substring(1, 20);
254 updateState(HOURLY_TO, new DateTimeType(timestampHourlyTo));
256 updateChannel(HOURLY_COST, myObject.get("cost").toString());
257 updateChannel(HOURLY_CONSUMPTION, myObject.get("consumption").toString());
258 } catch (JsonSyntaxException e) {
259 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
260 "Error communicating with Tibber API: " + e.getMessage());
263 } else if (jsonResponse.contains("error")) {
264 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
265 "Error in response from Tibber API: " + jsonResponse);
267 Thread.sleep(300 * 1000);
269 } catch (InterruptedException e) {
270 logger.debug("Tibber OFFLINE, attempting thread sleep: {}", e.getMessage());
273 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
274 "Unexpected response from Tibber: " + jsonResponse);
276 Thread.sleep(300 * 1000);
278 } catch (InterruptedException e) {
279 logger.debug("Tibber OFFLINE, attempting thread sleep: {}", e.getMessage());
284 public void startRefresh(int refresh) {
285 if (pollingJob == null) {
286 pollingJob = scheduler.scheduleWithFixedDelay(() -> {
289 } catch (IOException e) {
290 logger.warn("IO Exception: {}", e.getMessage());
292 }, 1, refresh, TimeUnit.MINUTES);
296 public void updateRequest() throws IOException {
297 getURLInput(BASE_URL);
298 if ("true".equals(rtEnabled) && !isConnected()) {
299 logger.debug("Attempting to reopen Websocket connection");
304 public void updateChannel(String channelID, String channelValue) {
305 if (!channelValue.contains("null")) {
306 if (channelID.contains("consumption") || channelID.contains("Consumption")
307 || channelID.contains("accumulatedProduction")) {
308 updateState(channelID, new QuantityType<>(new BigDecimal(channelValue), Units.KILOWATT_HOUR));
309 } else if (channelID.contains("power") || channelID.contains("Power")) {
310 updateState(channelID, new QuantityType<>(new BigDecimal(channelValue), Units.WATT));
311 } else if (channelID.contains("voltage")) {
312 updateState(channelID, new QuantityType<>(new BigDecimal(channelValue), Units.VOLT));
313 } else if (channelID.contains("current")) {
314 updateState(channelID, new QuantityType<>(new BigDecimal(channelValue), Units.AMPERE));
316 updateState(channelID, new DecimalType(channelValue));
321 public void thingStatusChanged(ThingStatusInfo thingStatusInfo) {
322 logger.debug("Thing Status updated to {} for device: {}", thingStatusInfo.getStatus(), getThing().getUID());
323 if (thingStatusInfo.getStatus() != ThingStatus.ONLINE) {
324 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
325 "Unable to communicate with Tibber API");
330 public void dispose() {
331 ScheduledFuture<?> pollingJob = this.pollingJob;
332 if (pollingJob != null) {
333 pollingJob.cancel(true);
334 this.pollingJob = null;
338 WebSocketClient client = this.client;
339 if (client != null) {
341 logger.debug("DISPOSE - Stopping and Terminating Websocket connection");
343 } catch (Exception e) {
344 logger.warn("Websocket Client Stop Exception: {}", e.getMessage());
354 WebSocketClient client = this.client;
355 if (client == null || !client.isRunning() || !isConnected()) {
356 if (client != null) {
359 } catch (Exception e) {
360 logger.warn("OPEN FRAME - Failed to stop websocket client: {}", e.getMessage());
364 sslContextFactory = new SslContextFactory.Client(true);
365 sslContextFactory.setTrustAll(true);
366 sslContextFactory.setEndpointIdentificationAlgorithm(null);
368 client = new WebSocketClient(new HttpClient(sslContextFactory));
369 client.setMaxIdleTimeout(30 * 1000);
370 this.client = client;
372 TibberWebSocketListener socket = this.socket;
373 if (socket == null) {
374 logger.debug("New socket being created");
375 socket = new TibberWebSocketListener();
376 this.socket = socket;
379 ClientUpgradeRequest newRequest = new ClientUpgradeRequest();
380 newRequest.setHeader(HttpHeader.USER_AGENT.asString(),
381 "openHAB/Tibber " + versionString + " Tibber driver " + TIBBER_DRIVER);
382 newRequest.setHeader(HttpHeader.AUTHORIZATION.asString(), "Bearer " + tibberConfig.getToken());
383 newRequest.setSubProtocols("graphql-transport-ws");
386 logger.debug("Starting Websocket connection");
388 } catch (Exception e) {
389 logger.warn("Websocket Start Exception: {}", e.getMessage());
392 logger.debug("Connecting Websocket connection");
393 sessionFuture = client.connect(socket, new URI(subscriptionURL), newRequest);
395 Thread.sleep(10 * 1000);
396 } catch (InterruptedException e) {
398 if (!isConnected()) {
399 logger.warn("Unable to establish websocket session - Reattempting connection on next refresh");
401 logger.debug("Websocket session established");
403 } catch (IOException e) {
404 logger.warn("Websocket Connect Exception: {}", e.getMessage());
405 } catch (URISyntaxException e) {
406 logger.warn("Websocket URI Exception: {}", e.getMessage());
409 logger.warn("Open: Websocket client already running");
413 public void close() {
414 Session session = this.session;
415 if (session != null) {
416 String disconnect = "{\"type\":\"connection_terminate\",\"payload\":null}";
418 TibberWebSocketListener socket = this.socket;
419 if (socket != null) {
420 logger.debug("Sending websocket disconnect message");
421 socket.sendMessage(disconnect);
423 logger.warn("Socket unable to send disconnect message: Socket is null");
425 } catch (IOException e) {
426 logger.warn("Websocket Close Exception: {}", e.getMessage());
430 } catch (Exception e) {
431 logger.warn("Unable to disconnect session");
436 Future<?> sessionFuture = this.sessionFuture;
437 if (sessionFuture != null && !sessionFuture.isDone()) {
438 sessionFuture.cancel(true);
440 WebSocketClient client = this.client;
441 if (client != null) {
444 } catch (Exception e) {
445 logger.warn("CLOSE FRAME - Failed to stop websocket client: {}", e.getMessage());
451 public boolean isConnected() {
452 Session session = this.session;
453 return session != null && session.isOpen();
457 public class TibberWebSocketListener {
460 public void onConnect(Session wssession) {
461 TibberHandler.this.session = wssession;
462 TibberWebSocketListener socket = TibberHandler.this.socket;
463 String connection = "{\"type\":\"connection_init\", \"payload\":{\"token\":\"" + tibberConfig.getToken()
466 if (socket != null) {
467 logger.debug("Sending websocket connect message");
468 socket.sendMessage(connection);
470 logger.debug("Socket unable to send connect message: Socket is null");
472 } catch (IOException e) {
473 logger.warn("Send Message Exception: {}", e.getMessage());
478 public void onClose(int statusCode, String reason) {
479 logger.debug("Closing a WebSocket due to {}", reason);
480 WebSocketClient client = TibberHandler.this.client;
481 if (client != null && client.isRunning()) {
483 logger.debug("ONCLOSE - Stopping and Terminating Websocket connection");
485 } catch (Exception e) {
486 logger.warn("Websocket Client Stop Exception: {}", e.getMessage());
492 public void onWebSocketError(Throwable e) {
493 String message = e.getMessage();
494 logger.debug("Error during websocket communication: {}", message);
499 public void onMessage(String message) {
500 if (message.contains("connection_ack")) {
501 logger.debug("Connected to Server");
503 } else if (message.contains("error") || message.contains("terminate")) {
504 logger.debug("Error/terminate received from server: {}", message);
506 } else if (message.contains("liveMeasurement")) {
507 JsonObject object = (JsonObject) JsonParser.parseString(message);
508 JsonObject myObject = object.getAsJsonObject("payload").getAsJsonObject("data")
509 .getAsJsonObject("liveMeasurement");
510 if (myObject.has("timestamp")) {
511 String liveTimestamp = myObject.get("timestamp").toString().substring(1, 20);
512 updateState(LIVE_TIMESTAMP, new DateTimeType(liveTimestamp));
514 if (myObject.has("power")) {
515 updateChannel(LIVE_POWER, myObject.get("power").toString());
517 if (myObject.has("lastMeterConsumption")) {
518 updateChannel(LIVE_LASTMETERCONSUMPTION, myObject.get("lastMeterConsumption").toString());
520 if (myObject.has("accumulatedConsumption")) {
521 updateChannel(LIVE_ACCUMULATEDCONSUMPTION, myObject.get("accumulatedConsumption").toString());
523 if (myObject.has("accumulatedCost")) {
524 updateChannel(LIVE_ACCUMULATEDCOST, myObject.get("accumulatedCost").toString());
526 if (myObject.has("accumulatedReward")) {
527 updateChannel(LIVE_ACCUMULATEREWARD, myObject.get("accumulatedReward").toString());
529 if (myObject.has("currency")) {
530 updateState(LIVE_CURRENCY, new StringType(myObject.get("currency").toString()));
532 if (myObject.has("minPower")) {
533 updateChannel(LIVE_MINPOWER, myObject.get("minPower").toString());
535 if (myObject.has("averagePower")) {
536 updateChannel(LIVE_AVERAGEPOWER, myObject.get("averagePower").toString());
538 if (myObject.has("maxPower")) {
539 updateChannel(LIVE_MAXPOWER, myObject.get("maxPower").toString());
541 if (myObject.has("voltagePhase1")) {
542 updateChannel(LIVE_VOLTAGE1, myObject.get("voltagePhase1").toString());
544 if (myObject.has("voltagePhase2")) {
545 updateChannel(LIVE_VOLTAGE2, myObject.get("voltagePhase2").toString());
547 if (myObject.has("voltagePhase3")) {
548 updateChannel(LIVE_VOLTAGE3, myObject.get("voltagePhase3").toString());
550 if (myObject.has("currentL1")) {
551 updateChannel(LIVE_CURRENT1, myObject.get("currentL1").toString());
553 if (myObject.has("currentL2")) {
554 updateChannel(LIVE_CURRENT2, myObject.get("currentL2").toString());
556 if (myObject.has("currentL3")) {
557 updateChannel(LIVE_CURRENT3, myObject.get("currentL3").toString());
559 if (myObject.has("powerProduction")) {
560 updateChannel(LIVE_POWERPRODUCTION, myObject.get("powerProduction").toString());
562 if (myObject.has("accumulatedProduction")) {
563 updateChannel(LIVE_ACCUMULATEDPRODUCTION, myObject.get("accumulatedProduction").toString());
565 if (myObject.has("minPowerProduction")) {
566 updateChannel(LIVE_MINPOWERPRODUCTION, myObject.get("minPowerProduction").toString());
568 if (myObject.has("maxPowerProduction")) {
569 updateChannel(LIVE_MAXPOWERPRODUCTION, myObject.get("maxPowerProduction").toString());
572 logger.debug("Unknown live response from Tibber");
576 private void sendMessage(String message) throws IOException {
577 logger.debug("Send message: {}", message);
578 Session session = TibberHandler.this.session;
579 if (session != null) {
580 session.getRemote().sendString(message);
584 public void startSubscription() {
585 String query = "{\"id\":\"1\",\"type\":\"subscribe\",\"payload\":{\"variables\":{},\"extensions\":{},\"operationName\":null,\"query\":\"subscription {\\n liveMeasurement(homeId:\\\""
586 + tibberConfig.getHomeid()
587 + "\\\") {\\n timestamp\\n power\\n lastMeterConsumption\\n accumulatedConsumption\\n accumulatedCost\\n accumulatedReward\\n currency\\n minPower\\n averagePower\\n maxPower\\n"
588 + "voltagePhase1\\n voltagePhase2\\n voltagePhase3\\n currentL1\\n currentL2\\n currentL3\\n powerProduction\\n accumulatedProduction\\n minPowerProduction\\n maxPowerProduction\\n }\\n }\\n\"}}";
590 TibberWebSocketListener socket = TibberHandler.this.socket;
591 if (socket != null) {
592 socket.sendMessage(query);
594 logger.debug("Socket unable to send subscription message: Socket is null");
596 } catch (IOException e) {
597 logger.warn("Send Message Exception: {}", e.getMessage());