2 * Copyright (c) 2010-2022 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.mycroft.internal.api;
15 import java.io.IOException;
17 import java.util.HashSet;
19 import java.util.Objects;
20 import java.util.Optional;
22 import java.util.concurrent.ConcurrentHashMap;
23 import java.util.concurrent.Future;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.atomic.AtomicInteger;
26 import java.util.stream.Stream;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.eclipse.jetty.websocket.api.Session;
31 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
32 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
33 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
34 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
35 import org.eclipse.jetty.websocket.api.annotations.WebSocket;
36 import org.eclipse.jetty.websocket.client.WebSocketClient;
37 import org.openhab.binding.mycroft.internal.api.dto.BaseMessage;
38 import org.slf4j.Logger;
39 import org.slf4j.LoggerFactory;
41 import com.google.gson.Gson;
42 import com.google.gson.GsonBuilder;
45 * Establishes and keeps a websocket connection to the Mycroft bus
47 * @author Gwendal Roulleau - Initial contribution. Inspired by the deconz binding.
51 public class MycroftConnection {
52 private static final AtomicInteger INSTANCE_COUNTER = new AtomicInteger();
53 private final Logger logger = LoggerFactory.getLogger(MycroftConnection.class);
55 private final WebSocketClient client;
56 private final String socketName;
57 private final Gson gson;
59 private final MycroftConnectionListener connectionListener;
60 private final Map<MessageType, Set<MycroftMessageListener<? extends BaseMessage>>> listeners = new ConcurrentHashMap<>();
62 private ConnectionState connectionState = ConnectionState.DISCONNECTED;
63 private @Nullable Session session;
65 private static final int TIMEOUT_MILLISECONDS = 3000;
67 public MycroftConnection(MycroftConnectionListener listener, WebSocketClient client) {
68 this.connectionListener = listener;
70 this.client.setConnectTimeout(TIMEOUT_MILLISECONDS);
71 this.client.setMaxIdleTimeout(0);
72 this.socketName = "Websocket-Mycroft$" + System.currentTimeMillis() + "-" + INSTANCE_COUNTER.incrementAndGet();
74 GsonBuilder gsonBuilder = new GsonBuilder();
75 gsonBuilder.registerTypeAdapter(MessageType.class, new MessageTypeConverter());
76 gson = gsonBuilder.create();
79 public MycroftConnection(MycroftConnectionListener listener) {
80 this(listener, new WebSocketClient());
83 public void start(String ip, int port) {
84 if (connectionState == ConnectionState.CONNECTED) {
86 } else if (connectionState == ConnectionState.CONNECTING) {
87 logger.debug("{} already connecting", socketName);
89 } else if (connectionState == ConnectionState.DISCONNECTING) {
90 logger.warn("{} trying to re-connect while still disconnecting", socketName);
92 Future<Session> futureConnect = null;
94 URI destUri = URI.create("ws://" + ip + ":" + port + "/core");
96 logger.debug("Trying to connect {} to {}", socketName, destUri);
97 futureConnect = client.connect(this, destUri);
98 futureConnect.get(TIMEOUT_MILLISECONDS, TimeUnit.MILLISECONDS);
99 } catch (Exception e) {
100 if (futureConnect != null) {
101 futureConnect.cancel(true);
104 .connectionLost("Error while connecting: " + (e.getMessage() != null ? e.getMessage() : "unknown"));
108 public void close() {
110 connectionState = ConnectionState.DISCONNECTING;
112 } catch (Exception e) {
113 logger.debug("{} encountered an error while closing connection", socketName, e);
119 * The listener registered in this method will be called when a corresponding message will be detected
120 * on the Mycroft bus.
122 * @param messageType The message type to listen to.
123 * @param listener The listener will receive a callback when the requested message type will be detected on the bus.
125 public void registerListener(MessageType messageType, MycroftMessageListener<? extends BaseMessage> listener) {
126 Set<MycroftMessageListener<? extends BaseMessage>> messageTypeListeners = listeners.get(messageType);
127 if (messageTypeListeners == null) {
128 messageTypeListeners = new HashSet<MycroftMessageListener<? extends BaseMessage>>();
129 listeners.put(messageType, messageTypeListeners);
131 messageTypeListeners.add(listener);
134 public void unregisterListener(MessageType messageType, MycroftMessageListener<?> listener) {
135 Optional.ofNullable(listeners.get(messageType))
136 .ifPresent((messageTypeListeners) -> messageTypeListeners.remove(listener));
139 public void sendMessage(BaseMessage message) throws IOException {
140 sendMessage(gson.toJson(message));
143 public void sendMessage(String message) throws IOException {
144 final Session storedSession = this.session;
146 if (storedSession != null) {
147 storedSession.getRemote().sendString(message);
149 throw new IOException("Session is not initialized");
151 } catch (IOException e) {
152 if (storedSession != null && storedSession.isOpen()) {
153 storedSession.close(-1, "Sending message error");
160 public void onConnect(Session session) {
161 connectionState = ConnectionState.CONNECTED;
162 logger.debug("{} successfully connected to {}: {}", socketName, session.getRemoteAddress().getAddress(),
164 connectionListener.connectionEstablished();
165 this.session = session;
169 public void onMessage(Session session, String message) {
170 if (!session.equals(this.session)) {
171 handleWrongSession(session, message);
174 logger.trace("{} received raw data: {}", socketName, message);
177 // get the base message information :
178 BaseMessage mycroftMessage = gson.fromJson(message, BaseMessage.class);
179 Objects.requireNonNull(mycroftMessage);
180 // now that we have the message type, we can use a second and more precise parsing:
181 if (mycroftMessage.type != MessageType.any) {
182 mycroftMessage = gson.fromJson(message, mycroftMessage.type.getMessageTypeClass());
183 Objects.requireNonNull(mycroftMessage);
185 // adding the raw message:
186 mycroftMessage.message = message;
188 final BaseMessage finalMessage = mycroftMessage;
189 Stream.concat(listeners.getOrDefault(MessageType.any, new HashSet<>()).stream(),
190 listeners.getOrDefault(mycroftMessage.type, new HashSet<>()).stream()).forEach(listener -> {
191 listener.baseMessageReceived(finalMessage);
194 } catch (RuntimeException e) {
195 // we need to catch all processing exceptions, otherwise they could affect the connection
196 logger.debug("{} encountered an error while processing the message {}: {}", socketName, message,
202 public void onError(@Nullable Session session, Throwable cause) {
204 if (session == null || !session.equals(this.session)) {
205 handleWrongSession(session, "Connection error: " + cause.getMessage());
208 logger.debug("{} connection error, closing: {}", socketName, cause.getMessage());
210 Session storedSession = this.session;
211 if (storedSession != null && storedSession.isOpen()) {
212 storedSession.close(-1, "Processing error");
217 public void onClose(Session session, int statusCode, String reason) {
218 if (!session.equals(this.session)) {
219 handleWrongSession(session, "Connection closed: " + statusCode + " / " + reason);
222 logger.trace("{} closed connection: {} / {}", socketName, statusCode, reason);
223 connectionState = ConnectionState.DISCONNECTED;
225 connectionListener.connectionLost(reason);
228 private void handleWrongSession(@Nullable Session session, String message) {
229 if (session == null) {
230 logger.debug("received and discarded message for null session : {}", message);
232 logger.debug("{} received and discarded message for other session {}: {}.", socketName, session.hashCode(),
238 * check connection state (successfully connected)
240 * @return true if connected, false if connecting, disconnecting or disconnected
242 public boolean isConnected() {
243 return connectionState == ConnectionState.CONNECTED;
247 * used internally to represent the connection state
249 private enum ConnectionState {