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.orbitbhyve.internal.handler;
15 import static org.openhab.binding.orbitbhyve.internal.OrbitBhyveBindingConstants.*;
17 import java.io.IOException;
19 import java.text.SimpleDateFormat;
20 import java.util.ArrayList;
21 import java.util.Arrays;
22 import java.util.Collection;
23 import java.util.Collections;
24 import java.util.Date;
25 import java.util.List;
26 import java.util.concurrent.ExecutionException;
27 import java.util.concurrent.Future;
28 import java.util.concurrent.ScheduledFuture;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.TimeoutException;
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.eclipse.jetty.client.HttpClient;
35 import org.eclipse.jetty.client.api.ContentResponse;
36 import org.eclipse.jetty.client.api.Request;
37 import org.eclipse.jetty.client.util.StringContentProvider;
38 import org.eclipse.jetty.http.HttpMethod;
39 import org.eclipse.jetty.websocket.api.Session;
40 import org.eclipse.jetty.websocket.api.WebSocketException;
41 import org.eclipse.jetty.websocket.client.WebSocketClient;
42 import org.openhab.binding.orbitbhyve.internal.OrbitBhyveConfiguration;
43 import org.openhab.binding.orbitbhyve.internal.discovery.OrbitBhyveDiscoveryService;
44 import org.openhab.binding.orbitbhyve.internal.model.OrbitBhyveDevice;
45 import org.openhab.binding.orbitbhyve.internal.model.OrbitBhyveProgram;
46 import org.openhab.binding.orbitbhyve.internal.model.OrbitBhyveSessionResponse;
47 import org.openhab.binding.orbitbhyve.internal.model.OrbitBhyveSocketEvent;
48 import org.openhab.binding.orbitbhyve.internal.net.OrbitBhyveSocket;
49 import org.openhab.core.config.core.status.ConfigStatusMessage;
50 import org.openhab.core.library.types.OnOffType;
51 import org.openhab.core.library.types.StringType;
52 import org.openhab.core.thing.Bridge;
53 import org.openhab.core.thing.Channel;
54 import org.openhab.core.thing.ChannelUID;
55 import org.openhab.core.thing.Thing;
56 import org.openhab.core.thing.ThingStatus;
57 import org.openhab.core.thing.ThingStatusDetail;
58 import org.openhab.core.thing.binding.ConfigStatusBridgeHandler;
59 import org.openhab.core.thing.binding.ThingHandler;
60 import org.openhab.core.thing.binding.ThingHandlerService;
61 import org.openhab.core.types.Command;
62 import org.slf4j.Logger;
63 import org.slf4j.LoggerFactory;
65 import com.google.gson.Gson;
68 * The {@link OrbitBhyveBridgeHandler} is responsible for handling commands, which are
69 * sent to one of the channels.
71 * @author Ondrej Pecta - Initial contribution
74 public class OrbitBhyveBridgeHandler extends ConfigStatusBridgeHandler {
76 private final Logger logger = LoggerFactory.getLogger(OrbitBhyveBridgeHandler.class);
78 private final HttpClient httpClient;
80 private final WebSocketClient webSocketClient;
82 private @Nullable ScheduledFuture<?> future = null;
84 private @Nullable Session session;
86 private @Nullable String sessionToken = null;
88 private OrbitBhyveConfiguration config = new OrbitBhyveConfiguration();
90 private final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
93 private final Gson gson = new Gson();
95 public OrbitBhyveBridgeHandler(Bridge thing, HttpClient httpClient, WebSocketClient webSocketClient) {
97 this.httpClient = httpClient;
98 this.webSocketClient = webSocketClient;
102 public Collection<ConfigStatusMessage> getConfigStatus() {
103 return Collections.emptyList();
107 public void handleCommand(ChannelUID channelUID, Command command) {
111 public Collection<Class<? extends ThingHandlerService>> getServices() {
112 return Collections.singleton(OrbitBhyveDiscoveryService.class);
116 public void initialize() {
117 config = getConfigAs(OrbitBhyveConfiguration.class);
118 httpClient.setFollowRedirects(false);
120 scheduler.execute(() -> {
122 future = scheduler.scheduleWithFixedDelay(this::ping, 0, config.refresh, TimeUnit.SECONDS);
124 logger.debug("Finished initializing!");
128 public void dispose() {
129 ScheduledFuture<?> localFuture = future;
130 if (localFuture != null) {
131 localFuture.cancel(true);
137 private boolean login() {
139 String urlParameters = "{\"session\":{\"email\":\"" + config.email + "\",\"password\":\"" + config.password
141 ContentResponse response = httpClient.newRequest(BHYVE_SESSION).method(HttpMethod.POST).agent(AGENT)
142 .content(new StringContentProvider(urlParameters), "application/json; charset=utf-8")
143 .timeout(BHYVE_TIMEOUT, TimeUnit.SECONDS).send();
144 if (response.getStatus() == 200) {
145 if (logger.isTraceEnabled()) {
146 logger.trace("response: {}", response.getContentAsString());
148 OrbitBhyveSessionResponse session = gson.fromJson(response.getContentAsString(),
149 OrbitBhyveSessionResponse.class);
150 sessionToken = session.getOrbitSessionToken();
151 logger.debug("token: {}", sessionToken);
152 initializeWebSocketSession();
154 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
155 "Login response status:" + response.getStatus());
158 } catch (TimeoutException | ExecutionException e) {
159 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Exception during login");
161 } catch (InterruptedException e) {
162 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Exception during login");
163 Thread.currentThread().interrupt();
166 updateStatus(ThingStatus.ONLINE);
170 private synchronized void ping() {
171 if (ThingStatus.OFFLINE == thing.getStatus()) {
175 if (ThingStatus.ONLINE == thing.getStatus()) {
176 Session localSession = session;
177 if (localSession == null || !localSession.isOpen()) {
178 initializeWebSocketSession();
180 localSession = session;
181 if (localSession != null && localSession.isOpen() && localSession.getRemote() != null) {
183 logger.debug("Sending ping");
184 localSession.getRemote().sendString("{\"event\":\"ping\"}");
186 } catch (IOException e) {
187 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
188 "Error sending ping (IOException on web socket)");
189 } catch (WebSocketException e) {
190 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
191 String.format("Error sending ping (WebSocketException: %s)", e.getMessage()));
194 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Web socket creation error");
199 public List<OrbitBhyveDevice> getDevices() {
201 ContentResponse response = sendRequestBuilder(BHYVE_DEVICES, HttpMethod.GET).send();
202 if (response.getStatus() == 200) {
203 if (logger.isTraceEnabled()) {
204 logger.trace("Devices response: {}", response.getContentAsString());
206 OrbitBhyveDevice[] devices = gson.fromJson(response.getContentAsString(), OrbitBhyveDevice[].class);
207 return Arrays.asList(devices);
209 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
210 "Get devices returned response status: " + response.getStatus());
212 } catch (TimeoutException | ExecutionException e) {
213 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error during getting devices");
214 } catch (InterruptedException e) {
215 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error during getting devices");
216 Thread.currentThread().interrupt();
218 return new ArrayList<>();
221 Request sendRequestBuilder(String uri, HttpMethod method) {
222 return httpClient.newRequest(uri).method(method).agent(AGENT).header("Orbit-Session-Token", sessionToken)
223 .timeout(BHYVE_TIMEOUT, TimeUnit.SECONDS);
226 public @Nullable OrbitBhyveDevice getDevice(String deviceId) {
228 ContentResponse response = sendRequestBuilder(BHYVE_DEVICES + "/" + deviceId, HttpMethod.GET).send();
229 if (response.getStatus() == 200) {
230 if (logger.isTraceEnabled()) {
231 logger.trace("Device response: {}", response.getContentAsString());
233 OrbitBhyveDevice device = gson.fromJson(response.getContentAsString(), OrbitBhyveDevice.class);
236 logger.debug("Returned status: {}", response.getStatus());
237 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
238 "Returned status: " + response.getStatus());
240 } catch (TimeoutException | ExecutionException e) {
241 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
242 "Error during getting device info: " + deviceId);
243 } catch (InterruptedException e) {
244 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
245 "Error during getting device info: " + deviceId);
246 Thread.currentThread().interrupt();
251 public synchronized void processStatusResponse(String content) {
252 updateStatus(ThingStatus.ONLINE);
253 logger.trace("Got message: {}", content);
254 OrbitBhyveSocketEvent event = gson.fromJson(content, OrbitBhyveSocketEvent.class);
260 private void processEvent(OrbitBhyveSocketEvent event) {
261 switch (event.getEvent()) {
262 case "watering_in_progress_notification":
263 disableZones(event.getDeviceId());
264 Channel channel = getThingChannel(event.getDeviceId(), event.getStation());
265 if (channel != null) {
266 logger.debug("Watering zone: {}", event.getStation());
267 updateState(channel.getUID(), OnOffType.ON);
268 String program = event.getProgram().getAsString();
269 if (!program.isEmpty() && !"manual".equals(program)) {
270 channel = getThingChannel(event.getDeviceId(), "program_" + program);
271 if (channel != null) {
272 updateState(channel.getUID(), OnOffType.ON);
277 case "watering_complete":
278 logger.debug("Watering complete");
279 disableZones(event.getDeviceId());
280 disablePrograms(event.getDeviceId());
281 updateDeviceStatus(event.getDeviceId());
284 logger.debug("Updating mode to: {}", event.getMode());
285 Channel ch = getThingChannel(event.getDeviceId(), CHANNEL_MODE);
287 updateState(ch.getUID(), new StringType(event.getMode()));
289 ch = getThingChannel(event.getDeviceId(), CHANNEL_CONTROL);
291 updateState(ch.getUID(), "off".equals(event.getMode()) ? OnOffType.OFF : OnOffType.ON);
293 updateDeviceStatus(event.getDeviceId());
296 updateDeviceStatus(event.getDeviceId());
298 case "skip_active_station":
299 disableZones(event.getDeviceId());
301 case "program_changed":
302 OrbitBhyveProgram program = gson.fromJson(event.getProgram(), OrbitBhyveProgram.class);
303 if (program != null) {
304 updateDeviceProgramStatus(program);
305 updateDeviceStatus(program.getDeviceId());
309 logger.debug("Received event: {}", event.getEvent());
313 private void updateAllStatuses() {
314 List<OrbitBhyveDevice> devices = getDevices();
315 for (Thing th : getThing().getThings()) {
316 if (th.isEnabled()) {
317 String deviceId = th.getUID().getId();
318 ThingHandler handler = th.getHandler();
319 if (handler instanceof OrbitBhyveSprinklerHandler) {
320 for (OrbitBhyveDevice device : devices) {
321 if (deviceId.equals(th.getUID().getId())) {
322 updateDeviceStatus(device, (OrbitBhyveSprinklerHandler) handler);
330 private void updateDeviceStatus(@Nullable OrbitBhyveDevice device, @Nullable OrbitBhyveSprinklerHandler handler) {
331 if (device != null && handler != null) {
332 handler.setDeviceOnline(device.isConnected());
333 handler.updateDeviceStatus(device.getStatus());
334 handler.updateSmartWatering(device.getWaterSenseMode());
339 private void updateDeviceStatus(String deviceId) {
340 for (Thing th : getThing().getThings()) {
341 if (deviceId.equals(th.getUID().getId())) {
342 ThingHandler handler = th.getHandler();
343 if (handler instanceof OrbitBhyveSprinklerHandler) {
344 OrbitBhyveDevice device = getDevice(deviceId);
345 updateDeviceStatus(device, (OrbitBhyveSprinklerHandler) handler);
351 private void updateDeviceProgramStatus(OrbitBhyveProgram program) {
352 for (Thing th : getThing().getThings()) {
353 if (program.getDeviceId().equals(th.getUID().getId())) {
354 ThingHandler handler = th.getHandler();
355 if (handler instanceof OrbitBhyveSprinklerHandler) {
356 ((OrbitBhyveSprinklerHandler) handler).updateProgram(program);
362 private void disableZones(String deviceId) {
363 disableChannel(deviceId, "zone_");
366 private void disablePrograms(String deviceId) {
367 disableChannel(deviceId, "program_");
370 private void disableChannel(String deviceId, String name) {
371 for (Thing th : getThing().getThings()) {
372 if (deviceId.equals(th.getUID().getId())) {
373 for (Channel ch : th.getChannels()) {
374 if (ch.getUID().getId().startsWith(name)) {
375 updateState(ch.getUID(), OnOffType.OFF);
383 private @Nullable Channel getThingChannel(String deviceId, int station) {
384 for (Thing th : getThing().getThings()) {
385 if (deviceId.equals(th.getUID().getId())) {
386 return th.getChannel("zone_" + station);
389 logger.debug("Cannot find zone: {} for device: {}", station, deviceId);
393 private @Nullable Channel getThingChannel(String deviceId, String name) {
394 for (Thing th : getThing().getThings()) {
395 if (deviceId.equals(th.getUID().getId())) {
396 return th.getChannel(name);
399 logger.debug("Cannot find channel: {} for device: {}", name, deviceId);
403 private @Nullable Session createSession() {
404 String url = BHYVE_WS_URL;
405 URI uri = URI.create(url);
408 // The socket that receives events
409 OrbitBhyveSocket socket = new OrbitBhyveSocket(this);
411 Future<Session> fut = webSocketClient.connect(socket, uri);
414 } catch (IOException e) {
415 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Cannot connect websocket client");
416 } catch (InterruptedException e) {
417 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Cannot create websocket session");
418 Thread.currentThread().interrupt();
419 } catch (ExecutionException e) {
420 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Cannot create websocket session");
425 private synchronized void initializeWebSocketSession() {
426 logger.debug("Initializing WebSocket session");
428 session = createSession();
429 Session localSession = session;
430 if (localSession != null) {
431 logger.debug("WebSocket connected!");
433 String msg = "{\"event\":\"app_connection\",\"orbit_session_token\":\"" + sessionToken + "\"}";
434 logger.trace("sending message:\n {}", msg);
435 localSession.getRemote().sendString(msg);
436 } catch (IOException e) {
437 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
438 "Error sending hello string (IOException on web socket)");
439 } catch (WebSocketException e) {
440 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
441 String.format("Error sending hello string (WebSocketException: %s)", e.getMessage()));
446 private void closeSession() {
447 Session localSession = session;
448 if (localSession != null && localSession.isOpen()) {
449 localSession.close();
453 public void runZone(String deviceId, String zone, int time) {
454 String dateTime = format.format(new Date());
457 Session localSession = session;
458 if (localSession != null && localSession.isOpen() && localSession.getRemote() != null) {
459 localSession.getRemote()
460 .sendString("{\"event\":\"change_mode\",\"device_id\":\"" + deviceId + "\",\"timestamp\":\""
461 + dateTime + "\",\"mode\":\"manual\",\"stations\":[{\"station\":" + zone
462 + ",\"run_time\":" + time + "}]}");
464 } catch (IOException e) {
465 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
466 "Error during zone watering execution");
470 public void runProgram(String deviceId, String program) {
471 String dateTime = format.format(new Date());
474 Session localSession = session;
475 if (localSession != null && localSession.isOpen() && localSession.getRemote() != null) {
476 localSession.getRemote().sendString("{\"event\":\"change_mode\",\"mode\":\"manual\",\"program\":\""
477 + program + "\",\"device_id\":\"" + deviceId + "\",\"timestamp\":\"" + dateTime + "\"}");
479 } catch (IOException e) {
480 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
481 "Error sending program watering execution (IOException on web socket)");
482 } catch (WebSocketException e) {
483 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
484 String.format("Error sending program watering execution (WebSocketException: %s)", e.getMessage()));
488 public void enableProgram(OrbitBhyveProgram program, boolean enable) {
490 String payLoad = "{\"sprinkler_timer_program\":{\"id\":\"" + program.getId() + "\",\"device_id\":\""
491 + program.getDeviceId() + "\",\"program\":\"" + program.getProgram() + "\",\"enabled\":" + enable
493 logger.debug("updating program {} with data {}", program.getProgram(), payLoad);
494 ContentResponse response = sendRequestBuilder(BHYVE_PROGRAMS + "/" + program.getId(), HttpMethod.PUT)
495 .content(new StringContentProvider(payLoad), "application/json; charset=utf-8").send();
496 if (response.getStatus() == 200) {
497 if (logger.isTraceEnabled()) {
498 logger.trace("Enable programs response: {}", response.getContentAsString());
502 logger.debug("Returned status: {}", response.getStatus());
503 updateStatus(ThingStatus.OFFLINE);
505 } catch (TimeoutException | ExecutionException e) {
506 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error during updating programs");
507 } catch (InterruptedException e) {
508 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error during updating programs");
509 Thread.currentThread().interrupt();
513 public void setRainDelay(String deviceId, int delay) {
514 String dateTime = format.format(new Date());
517 Session localSession = session;
518 if (localSession != null && localSession.isOpen() && localSession.getRemote() != null) {
519 localSession.getRemote().sendString("{\"event\":\"rain_delay\",\"device_id\":\"" + deviceId
520 + "\",\"delay\":" + delay + ",\"timestamp\":\"" + dateTime + "\"}");
522 } catch (IOException e) {
523 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
524 "Error setting rain delay (IOException on web socket)");
525 } catch (WebSocketException e) {
526 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
527 String.format("Error setting rain delay (WebSocketException: %s)", e.getMessage()));
531 public void stopWatering(String deviceId) {
532 String dateTime = format.format(new Date());
535 Session localSession = session;
536 if (localSession != null && localSession.isOpen() && localSession.getRemote() != null) {
537 localSession.getRemote().sendString("{\"event\":\"change_mode\",\"device_id\":\"" + deviceId
538 + "\",\"timestamp\":\"" + dateTime + "\",\"mode\":\"manual\",\"stations\":[]}");
540 } catch (IOException e) {
541 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
542 "Error sending stop watering (IOException on web socket)");
543 } catch (WebSocketException e) {
544 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
545 String.format("Error sending stop watering (WebSocketException: %s)", e.getMessage()));
549 public List<OrbitBhyveProgram> getPrograms() {
551 ContentResponse response = sendRequestBuilder(BHYVE_PROGRAMS, HttpMethod.GET).send();
552 if (response.getStatus() == 200) {
553 if (logger.isTraceEnabled()) {
554 logger.trace("Programs response: {}", response.getContentAsString());
556 OrbitBhyveProgram[] devices = gson.fromJson(response.getContentAsString(), OrbitBhyveProgram[].class);
557 return Arrays.asList(devices);
559 logger.debug("Returned status: {}", response.getStatus());
560 updateStatus(ThingStatus.OFFLINE);
562 } catch (TimeoutException | ExecutionException e) {
563 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error during getting programs");
564 } catch (InterruptedException e) {
565 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error during getting programs");
566 Thread.currentThread().interrupt();
568 return new ArrayList<>();
571 public void changeRunMode(String deviceId, String mode) {
572 String dateTime = format.format(new Date());
575 Session localSession = session;
576 if (localSession != null && localSession.isOpen() && localSession.getRemote() != null) {
577 localSession.getRemote().sendString("{\"event\":\"change_mode\",\"mode\":\"" + mode
578 + "\",\"device_id\":\"" + deviceId + "\",\"timestamp\":\"" + dateTime + "\"}");
580 } catch (IOException e) {
581 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
582 "Error setting run mode (IOException on web socket)");
583 } catch (WebSocketException e) {
584 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
585 String.format("Error setting run mode (WebSocketException: %s)", e.getMessage()));
589 public void setSmartWatering(String deviceId, boolean enable) {
590 OrbitBhyveDevice device = getDevice(deviceId);
591 if (device != null && device.getId().equals(deviceId)) {
592 device.setWaterSenseMode(enable ? "auto" : "off");
593 updateDevice(deviceId, gson.toJson(device));
597 private void updateDevice(String deviceId, String deviceString) {
598 String payload = "{\"device\":" + deviceString + "}";
599 logger.trace("New String: {}", payload);
601 ContentResponse response = sendRequestBuilder(BHYVE_DEVICES + "/" + deviceId, HttpMethod.PUT)
602 .content(new StringContentProvider(payload), "application/json;charset=UTF-8").send();
603 if (logger.isTraceEnabled()) {
604 logger.trace("Device update response: {}", response.getContentAsString());
606 if (response.getStatus() != 200) {
607 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
608 "Update device response status: " + response.getStatus());
610 } catch (TimeoutException | ExecutionException e) {
611 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error during updating device");
612 } catch (InterruptedException e) {
613 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error during updating device");
614 Thread.currentThread().interrupt();