2 * Copyright (c) 2010-2020 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.zoneminder.internal.handler;
15 import static org.openhab.binding.zoneminder.internal.ZmBindingConstants.*;
17 import java.io.ByteArrayInputStream;
18 import java.io.IOException;
19 import java.nio.charset.StandardCharsets;
20 import java.util.ArrayList;
21 import java.util.Collection;
22 import java.util.Collections;
23 import java.util.List;
26 import java.util.concurrent.ConcurrentHashMap;
27 import java.util.concurrent.ExecutionException;
28 import java.util.concurrent.Future;
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.http.HttpHeader;
38 import org.eclipse.jetty.http.HttpMethod;
39 import org.eclipse.jetty.http.HttpStatus;
40 import org.openhab.binding.zoneminder.internal.ZmStateDescriptionOptionsProvider;
41 import org.openhab.binding.zoneminder.internal.config.ZmBridgeConfig;
42 import org.openhab.binding.zoneminder.internal.discovery.MonitorDiscoveryService;
43 import org.openhab.binding.zoneminder.internal.dto.EventDTO;
44 import org.openhab.binding.zoneminder.internal.dto.EventsDTO;
45 import org.openhab.binding.zoneminder.internal.dto.MonitorDTO;
46 import org.openhab.binding.zoneminder.internal.dto.MonitorItemDTO;
47 import org.openhab.binding.zoneminder.internal.dto.MonitorStateDTO;
48 import org.openhab.binding.zoneminder.internal.dto.MonitorStatusDTO;
49 import org.openhab.binding.zoneminder.internal.dto.MonitorsDTO;
50 import org.openhab.binding.zoneminder.internal.dto.VersionDTO;
51 import org.openhab.core.io.net.http.HttpUtil;
52 import org.openhab.core.library.types.OnOffType;
53 import org.openhab.core.library.types.RawType;
54 import org.openhab.core.library.types.StringType;
55 import org.openhab.core.thing.Bridge;
56 import org.openhab.core.thing.ChannelUID;
57 import org.openhab.core.thing.Thing;
58 import org.openhab.core.thing.ThingStatus;
59 import org.openhab.core.thing.ThingStatusDetail;
60 import org.openhab.core.thing.binding.BaseBridgeHandler;
61 import org.openhab.core.thing.binding.ThingHandler;
62 import org.openhab.core.thing.binding.ThingHandlerService;
63 import org.openhab.core.types.Command;
64 import org.openhab.core.types.RefreshType;
65 import org.openhab.core.types.StateOption;
66 import org.openhab.core.types.UnDefType;
67 import org.slf4j.Logger;
68 import org.slf4j.LoggerFactory;
70 import com.google.gson.Gson;
71 import com.google.gson.GsonBuilder;
72 import com.google.gson.JsonSyntaxException;
75 * The {@link ZmBridgeHandler} represents the Zoneminder server. It handles all communication
76 * with the Zoneminder server.
78 * @author Mark Hilbush - Initial contribution
81 public class ZmBridgeHandler extends BaseBridgeHandler {
83 private static final int MONITOR_REFRESH_INTERVAL_SECONDS = 10;
84 private static final int MONITOR_REFRESH_STARTUP_DELAY_SECONDS = 5;
86 private static final int API_TIMEOUT_MSEC = 10000;
88 private static final String LOGIN_PATH = "/api/host/login.json";
90 private static final String STREAM_IMAGE = "single";
91 private static final String STREAM_VIDEO = "jpeg";
93 private static final List<String> EMPTY_LIST = Collections.emptyList();
95 private static final Gson GSON = new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create();
97 private final Logger logger = LoggerFactory.getLogger(ZmBridgeHandler.class);
99 private @Nullable Future<?> refreshMonitorsJob;
101 private List<Monitor> savedMonitors = new ArrayList<>();
103 private String host = "";
104 private boolean useSSL;
105 private @Nullable String portNumber;
106 private String urlPath = DEFAULT_URL_PATH;
107 private int monitorRefreshInterval;
108 private boolean backgroundDiscoveryEnabled;
109 private int defaultAlarmDuration;
110 private @Nullable Integer defaultImageRefreshInterval;
112 private final HttpClient httpClient;
113 private final ZmStateDescriptionOptionsProvider stateDescriptionProvider;
115 private ZmAuth zmAuth;
117 // Maintain mapping of handler and monitor id
118 private final Map<String, ZmMonitorHandler> monitorHandlers = new ConcurrentHashMap<>();
120 public ZmBridgeHandler(Bridge thing, HttpClient httpClient,
121 ZmStateDescriptionOptionsProvider stateDescriptionProvider) {
123 this.httpClient = httpClient;
124 this.stateDescriptionProvider = stateDescriptionProvider;
125 // Default to use no authentication
126 zmAuth = new ZmAuth(this);
130 public void initialize() {
131 ZmBridgeConfig config = getConfigAs(ZmBridgeConfig.class);
134 value = config.refreshInterval;
135 monitorRefreshInterval = value == null ? MONITOR_REFRESH_INTERVAL_SECONDS : value;
137 value = config.defaultAlarmDuration;
138 defaultAlarmDuration = value == null ? DEFAULT_ALARM_DURATION_SECONDS : value;
140 defaultImageRefreshInterval = config.defaultImageRefreshInterval;
142 backgroundDiscoveryEnabled = config.discoveryEnabled;
143 logger.debug("Bridge: Background discovery is {}", backgroundDiscoveryEnabled == true ? "ENABLED" : "DISABLED");
146 useSSL = config.useSSL.booleanValue();
147 portNumber = config.portNumber != null ? Integer.toString(config.portNumber) : null;
148 urlPath = config.urlPath;
150 // If user and password are configured, then use Zoneminder authentication
151 if (config.user != null && config.pass != null) {
152 zmAuth = new ZmAuth(this, config.user, config.pass);
155 updateStatus(ThingStatus.ONLINE);
156 scheduleRefreshJob();
161 public void dispose() {
166 public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
167 String monitorId = (String) childThing.getConfiguration().get(CONFIG_MONITOR_ID);
168 monitorHandlers.put(monitorId, (ZmMonitorHandler) childHandler);
169 logger.debug("Bridge: Monitor handler was initialized for {} with id {}", childThing.getUID(), monitorId);
173 public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
174 String monitorId = (String) childThing.getConfiguration().get(CONFIG_MONITOR_ID);
175 monitorHandlers.remove(monitorId);
176 logger.debug("Bridge: Monitor handler was disposed for {} with id {}", childThing.getUID(), monitorId);
180 public void handleCommand(ChannelUID channelUID, Command command) {
181 switch (channelUID.getId()) {
182 case CHANNEL_IMAGE_MONITOR_ID:
183 handleMonitorIdCommand(command, CHANNEL_IMAGE_MONITOR_ID, CHANNEL_IMAGE_URL, STREAM_IMAGE);
185 case CHANNEL_VIDEO_MONITOR_ID:
186 handleMonitorIdCommand(command, CHANNEL_VIDEO_MONITOR_ID, CHANNEL_VIDEO_URL, STREAM_VIDEO);
191 private void handleMonitorIdCommand(Command command, String monitorIdChannelId, String urlChannelId, String type) {
192 if (command instanceof RefreshType || command == OnOffType.OFF) {
193 updateState(monitorIdChannelId, UnDefType.UNDEF);
194 updateState(urlChannelId, UnDefType.UNDEF);
195 } else if (command instanceof StringType) {
196 String id = command.toString();
197 if (isMonitorIdValid(id)) {
198 updateState(urlChannelId, new StringType(buildStreamUrl(id, type)));
200 updateState(monitorIdChannelId, UnDefType.UNDEF);
201 updateState(urlChannelId, UnDefType.UNDEF);
207 public Collection<Class<? extends ThingHandlerService>> getServices() {
208 return Collections.singleton(MonitorDiscoveryService.class);
211 public boolean isBackgroundDiscoveryEnabled() {
212 return backgroundDiscoveryEnabled;
215 public Integer getDefaultAlarmDuration() {
216 return defaultAlarmDuration;
219 public @Nullable Integer getDefaultImageRefreshInterval() {
220 return defaultImageRefreshInterval;
223 public List<Monitor> getSavedMonitors() {
224 return savedMonitors;
227 public Gson getGson() {
231 public void setFunction(String id, MonitorFunction function) {
232 if (!zmAuth.isAuthorized()) {
235 logger.debug("Bridge: Setting monitor {} function to {}", id, function);
236 executePost(buildUrl(String.format("/api/monitors/%s.json", id)),
237 String.format("Monitor[Function]=%s", function.toString()));
240 public void setEnabled(String id, OnOffType enabled) {
241 if (!zmAuth.isAuthorized()) {
244 logger.debug("Bridge: Setting monitor {} to {}", id, enabled);
245 executePost(buildUrl(String.format("/api/monitors/%s.json", id)),
246 String.format("Monitor[Enabled]=%s", enabled == OnOffType.ON ? "1" : "0"));
249 public void setAlarmOn(String id) {
250 if (!zmAuth.isAuthorized()) {
253 logger.debug("Bridge: Turning alarm ON for monitor {}", id);
254 setAlarm(buildUrl(String.format("/api/monitors/alarm/id:%s/command:on.json", id)));
257 public void setAlarmOff(String id) {
258 if (!zmAuth.isAuthorized()) {
261 logger.debug("Bridge: Turning alarm OFF for monitor {}", id);
262 setAlarm(buildUrl(String.format("/api/monitors/alarm/id:%s/command:off.json", id)));
265 public @Nullable RawType getImage(String id, @Nullable Integer imageRefreshIntervalSeconds) {
266 Integer localRefreshInterval = imageRefreshIntervalSeconds;
267 if (localRefreshInterval == null || localRefreshInterval.intValue() < 1 || !zmAuth.isAuthorized()) {
270 // Call should timeout just before the refresh interval
271 int timeout = Math.min((localRefreshInterval * 1000) - 500, API_TIMEOUT_MSEC);
272 Request request = httpClient.newRequest(buildStreamUrl(id, STREAM_IMAGE));
273 request.method(HttpMethod.GET);
274 request.timeout(timeout, TimeUnit.MILLISECONDS);
278 ContentResponse response = request.send();
279 if (response.getStatus() == HttpStatus.OK_200) {
280 RawType image = new RawType(response.getContent(), response.getHeaders().get(HttpHeader.CONTENT_TYPE));
283 errorMsg = String.format("HTTP GET failed: %d, %s", response.getStatus(), response.getReason());
285 } catch (TimeoutException e) {
286 errorMsg = String.format("TimeoutException: Call to Zoneminder API timed out after {} msec", timeout);
287 } catch (ExecutionException e) {
288 errorMsg = String.format("ExecutionException: %s", e.getMessage());
289 } catch (InterruptedException e) {
290 errorMsg = String.format("InterruptedException: %s", e.getMessage());
291 Thread.currentThread().interrupt();
293 logger.debug("{}", errorMsg);
297 @SuppressWarnings("null")
298 private synchronized List<Monitor> getMonitors() {
299 List<Monitor> monitorList = new ArrayList<>();
300 if (!zmAuth.isAuthorized()) {
304 String response = executeGet(buildUrl("/api/monitors.json"));
305 MonitorsDTO monitors = GSON.fromJson(response, MonitorsDTO.class);
306 if (monitors != null && monitors.monitorItems != null) {
307 List<StateOption> options = new ArrayList<>();
308 for (MonitorItemDTO monitorItem : monitors.monitorItems) {
309 MonitorDTO m = monitorItem.monitor;
310 MonitorStatusDTO mStatus = monitorItem.monitorStatus;
311 if (m != null && mStatus != null) {
312 Monitor monitor = new Monitor(m.id, m.name, m.function, m.enabled, mStatus.status);
313 monitor.setHourEvents(m.hourEvents);
314 monitor.setDayEvents(m.dayEvents);
315 monitor.setWeekEvents(m.weekEvents);
316 monitor.setMonthEvents(m.monthEvents);
317 monitor.setTotalEvents(m.totalEvents);
318 monitor.setImageUrl(buildStreamUrl(m.id, STREAM_IMAGE));
319 monitor.setVideoUrl(buildStreamUrl(m.id, STREAM_VIDEO));
320 monitorList.add(monitor);
321 options.add(new StateOption(m.id, "Monitor " + m.id));
323 stateDescriptionProvider
324 .setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_IMAGE_MONITOR_ID), options);
325 stateDescriptionProvider
326 .setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_VIDEO_MONITOR_ID), options);
328 // Only update alarm and event info for monitors whose handlers are initialized
329 Set<String> ids = monitorHandlers.keySet();
330 for (Monitor m : monitorList) {
331 if (ids.contains(m.getId())) {
332 m.setState(getState(m.getId()));
333 m.setLastEvent(getLastEvent(m.getId()));
337 } catch (JsonSyntaxException e) {
338 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
343 @SuppressWarnings("null")
344 private @Nullable Event getLastEvent(String id) {
345 if (!zmAuth.isAuthorized()) {
349 List<String> parameters = new ArrayList<>();
350 parameters.add("sort=StartTime");
351 parameters.add("direction=desc");
352 parameters.add("limit=1");
353 String response = executeGet(
354 buildUrlWithParameters(String.format("/api/events/index/MonitorId:%s.json", id), parameters));
355 EventsDTO events = GSON.fromJson(response, EventsDTO.class);
356 if (events != null && events.eventsList != null && events.eventsList.size() == 1) {
357 EventDTO e = events.eventsList.get(0).event;
358 Event event = new Event(e.eventId, e.name, e.cause, e.notes, e.startTime, e.endTime);
359 event.setFrames(e.frames);
360 event.setAlarmFrames(e.alarmFrames);
361 event.setLength(e.length);
364 } catch (JsonSyntaxException e) {
365 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
370 private @Nullable VersionDTO getVersion() {
371 if (!zmAuth.isAuthorized()) {
374 VersionDTO version = null;
376 String response = executeGet(buildUrl("/api/host/getVersion.json"));
377 version = GSON.fromJson(response, VersionDTO.class);
378 } catch (JsonSyntaxException e) {
379 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
384 private void setAlarm(String url) {
388 @SuppressWarnings("null")
389 private MonitorState getState(String id) {
390 if (!zmAuth.isAuthorized()) {
391 return MonitorState.UNKNOWN;
394 String response = executeGet(buildUrl(String.format("/api/monitors/alarm/id:%s/command:status.json", id)));
395 MonitorStateDTO monitorState = GSON.fromJson(response, MonitorStateDTO.class);
396 if (monitorState != null) {
397 MonitorState state = monitorState.state;
398 return state != null ? state : MonitorState.UNKNOWN;
400 } catch (JsonSyntaxException e) {
401 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
403 return MonitorState.UNKNOWN;
406 public @Nullable String executeGet(String url) {
408 long startTime = System.currentTimeMillis();
409 String response = HttpUtil.executeUrl("GET", url, API_TIMEOUT_MSEC);
410 logger.trace("Bridge: Http GET of '{}' returned '{}' in {} ms", url, response,
411 System.currentTimeMillis() - startTime);
413 } catch (IOException e) {
414 logger.debug("Bridge: IOException on GET request, url='{}': {}", url, e.getMessage());
419 private @Nullable String executePost(String url, String content) {
420 return executePost(url, content, "application/x-www-form-urlencoded");
423 public @Nullable String executePost(String url, String content, String contentType) {
424 try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
425 long startTime = System.currentTimeMillis();
426 String response = HttpUtil.executeUrl("POST", url, inputStream, contentType, API_TIMEOUT_MSEC);
427 logger.trace("Bridge: Http POST content '{}' to '{}' returned: {} in {} ms", content, url, response,
428 System.currentTimeMillis() - startTime);
430 } catch (IOException e) {
431 logger.debug("Bridge: IOException on POST request, url='{}': {}", url, e.getMessage());
436 public String buildLoginUrl() {
437 return buildBaseUrl(LOGIN_PATH).toString();
440 public String buildLoginUrl(String tokenParameter) {
441 StringBuilder sb = buildBaseUrl(LOGIN_PATH);
442 sb.append(tokenParameter);
443 return sb.toString();
446 private String buildStreamUrl(String id, String streamType) {
447 List<String> parameters = new ArrayList<>();
448 parameters.add(String.format("mode=%s", streamType));
449 parameters.add(String.format("monitor=%s", id));
450 return buildUrlWithParameters("/cgi-bin/zms", parameters);
453 private String buildUrl(String path) {
454 return buildUrlWithParameters(path, EMPTY_LIST);
457 private String buildUrlWithParameters(String path, List<String> parameters) {
458 StringBuilder sb = buildBaseUrl(path);
460 for (String parameter : parameters) {
461 sb.append(joiner).append(parameter);
464 if (zmAuth.usingAuthorization()) {
465 sb.append(joiner).append("token=").append(zmAuth.getAccessToken());
467 return sb.toString();
470 private StringBuilder buildBaseUrl(String path) {
471 StringBuilder sb = new StringBuilder();
472 sb.append(useSSL ? "https://" : "http://");
474 if (portNumber != null) {
475 sb.append(":").append(portNumber);
482 private boolean isMonitorIdValid(String id) {
483 return savedMonitors.stream().filter(monitor -> id.equals(monitor.getId())).findAny().isPresent();
486 private boolean isHostValid() {
487 logger.debug("Bridge: Checking for valid Zoneminder host: {}", host);
488 VersionDTO version = getVersion();
489 if (version != null) {
490 if (checkSoftwareVersion(version.version) && checkApiVersion(version.apiVersion)) {
494 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Can't get version information");
499 private boolean checkSoftwareVersion(@Nullable String softwareVersion) {
500 logger.debug("Bridge: Zoneminder software version is {}", softwareVersion);
501 if (softwareVersion != null) {
502 String[] versionParts = softwareVersion.split("\\.");
503 if (versionParts.length >= 2) {
505 int versionMajor = Integer.parseInt(versionParts[0]);
506 int versionMinor = Integer.parseInt(versionParts[1]);
507 if (versionMajor == 1 && versionMinor >= 34) {
508 logger.debug("Bridge: Zoneminder software version check OK");
511 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
512 .format("Current Zoneminder version: %s. Requires version >= 1.34.0", softwareVersion));
514 } catch (NumberFormatException e) {
515 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
516 String.format("Badly formatted version number: %s", softwareVersion));
519 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
520 String.format("Can't parse software version: %s", softwareVersion));
523 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Software version is null");
528 private boolean checkApiVersion(@Nullable String apiVersion) {
529 logger.debug("Bridge: Zoneminder API version is {}", apiVersion);
530 if (apiVersion != null) {
531 String[] versionParts = apiVersion.split("\\.");
532 if (versionParts.length >= 2) {
534 int versionMajor = Integer.parseInt(versionParts[0]);
535 if (versionMajor >= 2) {
536 logger.debug("Bridge: Zoneminder API version check OK");
539 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
540 .format("Requires API version >= 2.0. This Zoneminder is API version {}", apiVersion));
542 } catch (NumberFormatException e) {
543 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
544 String.format("Badly formatted API version: %s", apiVersion));
547 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
548 String.format("Can't parse API version: %s", apiVersion));
551 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "API version is null");
556 @SuppressWarnings("null")
557 private void refreshMonitors() {
558 List<Monitor> monitors = getMonitors();
559 savedMonitors = monitors;
560 for (Monitor monitor : monitors) {
561 ZmMonitorHandler handler = monitorHandlers.get(monitor.getId());
562 if (handler != null) {
563 handler.updateStatus(monitor);
568 private void scheduleRefreshJob() {
569 logger.debug("Bridge: Scheduling monitors refresh job");
571 refreshMonitorsJob = scheduler.scheduleWithFixedDelay(this::refreshMonitors,
572 MONITOR_REFRESH_STARTUP_DELAY_SECONDS, monitorRefreshInterval, TimeUnit.SECONDS);
575 private void cancelRefreshJob() {
576 Future<?> localRefreshThermostatsJob = refreshMonitorsJob;
577 if (localRefreshThermostatsJob != null) {
578 localRefreshThermostatsJob.cancel(true);
579 logger.debug("Bridge: Canceling monitors refresh job");
580 refreshMonitorsJob = null;