2 * Copyright (c) 2010-2021 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.EventSummaryDTO;
45 import org.openhab.binding.zoneminder.internal.dto.EventsDTO;
46 import org.openhab.binding.zoneminder.internal.dto.MonitorDTO;
47 import org.openhab.binding.zoneminder.internal.dto.MonitorItemDTO;
48 import org.openhab.binding.zoneminder.internal.dto.MonitorStateDTO;
49 import org.openhab.binding.zoneminder.internal.dto.MonitorStatusDTO;
50 import org.openhab.binding.zoneminder.internal.dto.MonitorsDTO;
51 import org.openhab.binding.zoneminder.internal.dto.VersionDTO;
52 import org.openhab.core.io.net.http.HttpUtil;
53 import org.openhab.core.library.types.OnOffType;
54 import org.openhab.core.library.types.RawType;
55 import org.openhab.core.library.types.StringType;
56 import org.openhab.core.thing.Bridge;
57 import org.openhab.core.thing.ChannelUID;
58 import org.openhab.core.thing.Thing;
59 import org.openhab.core.thing.ThingStatus;
60 import org.openhab.core.thing.ThingStatusDetail;
61 import org.openhab.core.thing.binding.BaseBridgeHandler;
62 import org.openhab.core.thing.binding.ThingHandler;
63 import org.openhab.core.thing.binding.ThingHandlerService;
64 import org.openhab.core.types.Command;
65 import org.openhab.core.types.RefreshType;
66 import org.openhab.core.types.StateOption;
67 import org.openhab.core.types.UnDefType;
68 import org.slf4j.Logger;
69 import org.slf4j.LoggerFactory;
71 import com.google.gson.Gson;
72 import com.google.gson.GsonBuilder;
73 import com.google.gson.JsonSyntaxException;
76 * The {@link ZmBridgeHandler} represents the Zoneminder server. It handles all communication
77 * with the Zoneminder server.
79 * @author Mark Hilbush - Initial contribution
82 public class ZmBridgeHandler extends BaseBridgeHandler {
84 private static final int MONITOR_REFRESH_INTERVAL_SECONDS = 10;
85 private static final int MONITOR_REFRESH_STARTUP_DELAY_SECONDS = 5;
87 private static final int API_TIMEOUT_MSEC = 10000;
89 private static final String LOGIN_PATH = "/api/host/login.json";
91 private static final String STREAM_IMAGE = "single";
92 private static final String STREAM_VIDEO = "jpeg";
94 private static final List<String> EMPTY_LIST = Collections.emptyList();
96 private static final Gson GSON = new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create();
98 private final Logger logger = LoggerFactory.getLogger(ZmBridgeHandler.class);
100 private @Nullable Future<?> refreshMonitorsJob;
102 private List<Monitor> savedMonitors = new ArrayList<>();
104 private String host = "";
105 private boolean useSSL;
106 private @Nullable String portNumber;
107 private String urlPath = DEFAULT_URL_PATH;
108 private int monitorRefreshInterval;
109 private boolean backgroundDiscoveryEnabled;
110 private int defaultAlarmDuration;
111 private @Nullable Integer defaultImageRefreshInterval;
113 private final HttpClient httpClient;
114 private final ZmStateDescriptionOptionsProvider stateDescriptionProvider;
116 private ZmAuth zmAuth;
118 // Maintain mapping of handler and monitor id
119 private final Map<String, ZmMonitorHandler> monitorHandlers = new ConcurrentHashMap<>();
121 public ZmBridgeHandler(Bridge thing, HttpClient httpClient,
122 ZmStateDescriptionOptionsProvider stateDescriptionProvider) {
124 this.httpClient = httpClient;
125 this.stateDescriptionProvider = stateDescriptionProvider;
126 // Default to use no authentication
127 zmAuth = new ZmAuth(this);
131 public void initialize() {
132 ZmBridgeConfig config = getConfigAs(ZmBridgeConfig.class);
135 value = config.refreshInterval;
136 monitorRefreshInterval = value == null ? MONITOR_REFRESH_INTERVAL_SECONDS : value;
138 value = config.defaultAlarmDuration;
139 defaultAlarmDuration = value == null ? DEFAULT_ALARM_DURATION_SECONDS : value;
141 defaultImageRefreshInterval = config.defaultImageRefreshInterval;
143 backgroundDiscoveryEnabled = config.discoveryEnabled;
144 logger.debug("Bridge: Background discovery is {}", backgroundDiscoveryEnabled == true ? "ENABLED" : "DISABLED");
147 useSSL = config.useSSL.booleanValue();
148 portNumber = config.portNumber != null ? Integer.toString(config.portNumber) : null;
149 urlPath = config.urlPath;
151 // If user and password are configured, then use Zoneminder authentication
152 if (config.user != null && config.pass != null) {
153 zmAuth = new ZmAuth(this, config.user, config.pass);
156 updateStatus(ThingStatus.ONLINE);
157 scheduleRefreshJob();
162 public void dispose() {
167 public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
168 String monitorId = (String) childThing.getConfiguration().get(CONFIG_MONITOR_ID);
169 monitorHandlers.put(monitorId, (ZmMonitorHandler) childHandler);
170 logger.debug("Bridge: Monitor handler was initialized for {} with id {}", childThing.getUID(), monitorId);
174 public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
175 String monitorId = (String) childThing.getConfiguration().get(CONFIG_MONITOR_ID);
176 monitorHandlers.remove(monitorId);
177 logger.debug("Bridge: Monitor handler was disposed for {} with id {}", childThing.getUID(), monitorId);
181 public void handleCommand(ChannelUID channelUID, Command command) {
182 switch (channelUID.getId()) {
183 case CHANNEL_IMAGE_MONITOR_ID:
184 handleMonitorIdCommand(command, CHANNEL_IMAGE_MONITOR_ID, CHANNEL_IMAGE_URL, STREAM_IMAGE);
186 case CHANNEL_VIDEO_MONITOR_ID:
187 handleMonitorIdCommand(command, CHANNEL_VIDEO_MONITOR_ID, CHANNEL_VIDEO_URL, STREAM_VIDEO);
192 private void handleMonitorIdCommand(Command command, String monitorIdChannelId, String urlChannelId, String type) {
193 if (command instanceof RefreshType || command == OnOffType.OFF) {
194 updateState(monitorIdChannelId, UnDefType.UNDEF);
195 updateState(urlChannelId, UnDefType.UNDEF);
196 } else if (command instanceof StringType) {
197 String id = command.toString();
198 if (isMonitorIdValid(id)) {
199 updateState(urlChannelId, new StringType(buildStreamUrl(id, type)));
201 updateState(monitorIdChannelId, UnDefType.UNDEF);
202 updateState(urlChannelId, UnDefType.UNDEF);
208 public Collection<Class<? extends ThingHandlerService>> getServices() {
209 return Collections.singleton(MonitorDiscoveryService.class);
212 public boolean isBackgroundDiscoveryEnabled() {
213 return backgroundDiscoveryEnabled;
216 public Integer getDefaultAlarmDuration() {
217 return defaultAlarmDuration;
220 public @Nullable Integer getDefaultImageRefreshInterval() {
221 return defaultImageRefreshInterval;
224 public List<Monitor> getSavedMonitors() {
225 return savedMonitors;
228 public Gson getGson() {
232 public void setFunction(String id, MonitorFunction function) {
233 if (!zmAuth.isAuthorized()) {
236 logger.debug("Bridge: Setting monitor {} function to {}", id, function);
237 executePost(buildUrl(String.format("/api/monitors/%s.json", id)),
238 String.format("Monitor[Function]=%s", function.toString()));
241 public void setEnabled(String id, OnOffType enabled) {
242 if (!zmAuth.isAuthorized()) {
245 logger.debug("Bridge: Setting monitor {} to {}", id, enabled);
246 executePost(buildUrl(String.format("/api/monitors/%s.json", id)),
247 String.format("Monitor[Enabled]=%s", enabled == OnOffType.ON ? "1" : "0"));
250 public void setAlarmOn(String id) {
251 if (!zmAuth.isAuthorized()) {
254 logger.debug("Bridge: Turning alarm ON for monitor {}", id);
255 setAlarm(buildUrl(String.format("/api/monitors/alarm/id:%s/command:on.json", id)));
258 public void setAlarmOff(String id) {
259 if (!zmAuth.isAuthorized()) {
262 logger.debug("Bridge: Turning alarm OFF for monitor {}", id);
263 setAlarm(buildUrl(String.format("/api/monitors/alarm/id:%s/command:off.json", id)));
266 public @Nullable RawType getImage(String id, @Nullable Integer imageRefreshIntervalSeconds) {
267 Integer localRefreshInterval = imageRefreshIntervalSeconds;
268 if (localRefreshInterval == null || localRefreshInterval.intValue() < 1 || !zmAuth.isAuthorized()) {
271 // Call should timeout just before the refresh interval
272 int timeout = Math.min((localRefreshInterval * 1000) - 500, API_TIMEOUT_MSEC);
273 Request request = httpClient.newRequest(buildStreamUrl(id, STREAM_IMAGE));
274 request.method(HttpMethod.GET);
275 request.timeout(timeout, TimeUnit.MILLISECONDS);
279 ContentResponse response = request.send();
280 if (response.getStatus() == HttpStatus.OK_200) {
281 RawType image = new RawType(response.getContent(), response.getHeaders().get(HttpHeader.CONTENT_TYPE));
284 errorMsg = String.format("HTTP GET failed: %d, %s", response.getStatus(), response.getReason());
286 } catch (TimeoutException e) {
287 errorMsg = String.format("TimeoutException: Call to Zoneminder API timed out after {} msec", timeout);
288 } catch (ExecutionException e) {
289 errorMsg = String.format("ExecutionException: %s", e.getMessage());
290 } catch (InterruptedException e) {
291 errorMsg = String.format("InterruptedException: %s", e.getMessage());
292 Thread.currentThread().interrupt();
294 logger.debug("{}", errorMsg);
298 @SuppressWarnings("null")
299 private synchronized List<Monitor> getMonitors() {
300 List<Monitor> monitorList = new ArrayList<>();
301 if (!zmAuth.isAuthorized()) {
305 String response = executeGet(buildUrl("/api/monitors.json"));
306 MonitorsDTO monitorsDTO = GSON.fromJson(response, MonitorsDTO.class);
307 if (monitorsDTO != null && monitorsDTO.monitorItems != null) {
308 List<StateOption> options = new ArrayList<>();
309 for (MonitorItemDTO monitorItemDTO : monitorsDTO.monitorItems) {
310 MonitorDTO monitorDTO = monitorItemDTO.monitor;
311 MonitorStatusDTO monitorStatusDTO = monitorItemDTO.monitorStatus;
312 if (monitorDTO != null && monitorStatusDTO != null) {
313 Monitor monitor = new Monitor(monitorDTO.id, monitorDTO.name, monitorDTO.function,
314 monitorDTO.enabled, monitorStatusDTO.status);
315 extractEventCounts(monitor, monitorItemDTO);
316 monitor.setImageUrl(buildStreamUrl(monitorDTO.id, STREAM_IMAGE));
317 monitor.setVideoUrl(buildStreamUrl(monitorDTO.id, STREAM_VIDEO));
318 monitorList.add(monitor);
319 options.add(new StateOption(monitorDTO.id, "Monitor " + monitorDTO.id));
321 stateDescriptionProvider
322 .setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_IMAGE_MONITOR_ID), options);
323 stateDescriptionProvider
324 .setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_VIDEO_MONITOR_ID), options);
326 // Only update alarm and event info for monitors whose handlers are initialized
327 Set<String> ids = monitorHandlers.keySet();
328 for (Monitor m : monitorList) {
329 if (ids.contains(m.getId())) {
330 m.setState(getState(m.getId()));
331 m.setLastEvent(getLastEvent(m.getId()));
335 } catch (JsonSyntaxException e) {
336 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
341 private void extractEventCounts(Monitor monitor, MonitorItemDTO monitorItemDTO) {
343 * The Zoneminder API changed in version 1.36.x such that the event counts moved from the
344 * monitor object to a new event summary object. Therefore, if the event summary object
345 * exists in the JSON response, pull the event counts from that object, otherwise get the
346 * counts from the monitor object.
348 if (monitorItemDTO.eventSummary != null) {
349 EventSummaryDTO eventSummaryDTO = monitorItemDTO.eventSummary;
350 monitor.setHourEvents(eventSummaryDTO.hourEvents);
351 monitor.setDayEvents(eventSummaryDTO.dayEvents);
352 monitor.setWeekEvents(eventSummaryDTO.weekEvents);
353 monitor.setMonthEvents(eventSummaryDTO.monthEvents);
354 monitor.setTotalEvents(eventSummaryDTO.totalEvents);
356 MonitorDTO monitorDTO = monitorItemDTO.monitor;
357 monitor.setHourEvents(monitorDTO.hourEvents);
358 monitor.setDayEvents(monitorDTO.dayEvents);
359 monitor.setWeekEvents(monitorDTO.weekEvents);
360 monitor.setMonthEvents(monitorDTO.monthEvents);
361 monitor.setTotalEvents(monitorDTO.totalEvents);
365 @SuppressWarnings("null")
366 private @Nullable Event getLastEvent(String id) {
367 if (!zmAuth.isAuthorized()) {
371 List<String> parameters = new ArrayList<>();
372 parameters.add("sort=StartTime");
373 parameters.add("direction=desc");
374 parameters.add("limit=1");
375 String response = executeGet(buildUrlWithParameters(
376 String.format("/api/events/index/MonitorId:%s/Name!=:New%%20Event.json", id), parameters));
377 EventsDTO events = GSON.fromJson(response, EventsDTO.class);
378 if (events != null && events.eventsList != null && events.eventsList.size() == 1) {
379 EventDTO e = events.eventsList.get(0).event;
380 Event event = new Event(e.eventId, e.name, e.cause, e.notes, e.startTime, e.endTime);
381 event.setFrames(e.frames);
382 event.setAlarmFrames(e.alarmFrames);
383 event.setLength(e.length);
386 } catch (JsonSyntaxException e) {
387 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
392 private @Nullable VersionDTO getVersion() {
393 if (!zmAuth.isAuthorized()) {
396 VersionDTO version = null;
398 String response = executeGet(buildUrl("/api/host/getVersion.json"));
399 version = GSON.fromJson(response, VersionDTO.class);
400 } catch (JsonSyntaxException e) {
401 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
406 private void setAlarm(String url) {
410 @SuppressWarnings("null")
411 private MonitorState getState(String id) {
412 if (!zmAuth.isAuthorized()) {
413 return MonitorState.UNKNOWN;
416 String response = executeGet(buildUrl(String.format("/api/monitors/alarm/id:%s/command:status.json", id)));
417 MonitorStateDTO monitorState = GSON.fromJson(response, MonitorStateDTO.class);
418 if (monitorState != null) {
419 MonitorState state = monitorState.state;
420 return state != null ? state : MonitorState.UNKNOWN;
422 } catch (JsonSyntaxException e) {
423 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
425 return MonitorState.UNKNOWN;
428 public @Nullable String executeGet(String url) {
430 long startTime = System.currentTimeMillis();
431 String response = HttpUtil.executeUrl("GET", url, API_TIMEOUT_MSEC);
432 logger.trace("Bridge: Http GET of '{}' returned '{}' in {} ms", url, response,
433 System.currentTimeMillis() - startTime);
435 } catch (IOException e) {
436 logger.debug("Bridge: IOException on GET request, url='{}': {}", url, e.getMessage());
441 private @Nullable String executePost(String url, String content) {
442 return executePost(url, content, "application/x-www-form-urlencoded");
445 public @Nullable String executePost(String url, String content, String contentType) {
446 try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
447 long startTime = System.currentTimeMillis();
448 String response = HttpUtil.executeUrl("POST", url, inputStream, contentType, API_TIMEOUT_MSEC);
449 logger.trace("Bridge: Http POST content '{}' to '{}' returned: {} in {} ms", content, url, response,
450 System.currentTimeMillis() - startTime);
452 } catch (IOException e) {
453 logger.debug("Bridge: IOException on POST request, url='{}': {}", url, e.getMessage());
458 public String buildLoginUrl() {
459 return buildBaseUrl(LOGIN_PATH).toString();
462 public String buildLoginUrl(String tokenParameter) {
463 StringBuilder sb = buildBaseUrl(LOGIN_PATH);
464 sb.append(tokenParameter);
465 return sb.toString();
468 private String buildStreamUrl(String id, String streamType) {
469 List<String> parameters = new ArrayList<>();
470 parameters.add(String.format("mode=%s", streamType));
471 parameters.add(String.format("monitor=%s", id));
472 return buildUrlWithParameters("/cgi-bin/zms", parameters);
475 private String buildUrl(String path) {
476 return buildUrlWithParameters(path, EMPTY_LIST);
479 private String buildUrlWithParameters(String path, List<String> parameters) {
480 StringBuilder sb = buildBaseUrl(path);
482 for (String parameter : parameters) {
483 sb.append(joiner).append(parameter);
486 if (zmAuth.usingAuthorization()) {
487 sb.append(joiner).append("token=").append(zmAuth.getAccessToken());
489 return sb.toString();
492 private StringBuilder buildBaseUrl(String path) {
493 StringBuilder sb = new StringBuilder();
494 sb.append(useSSL ? "https://" : "http://");
496 if (portNumber != null) {
497 sb.append(":").append(portNumber);
504 private boolean isMonitorIdValid(String id) {
505 return savedMonitors.stream().filter(monitor -> id.equals(monitor.getId())).findAny().isPresent();
508 private boolean isHostValid() {
509 logger.debug("Bridge: Checking for valid Zoneminder host: {}", host);
510 VersionDTO version = getVersion();
511 if (version != null) {
512 if (checkSoftwareVersion(version.version) && checkApiVersion(version.apiVersion)) {
516 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Can't get version information");
521 private boolean checkSoftwareVersion(@Nullable String softwareVersion) {
522 logger.debug("Bridge: Zoneminder software version is {}", softwareVersion);
523 if (softwareVersion != null) {
524 String[] versionParts = softwareVersion.split("\\.");
525 if (versionParts.length >= 2) {
527 int versionMajor = Integer.parseInt(versionParts[0]);
528 int versionMinor = Integer.parseInt(versionParts[1]);
529 if (versionMajor == 1 && versionMinor >= 34) {
530 logger.debug("Bridge: Zoneminder software version check OK");
533 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
534 .format("Current Zoneminder version: %s. Requires version >= 1.34.0", softwareVersion));
536 } catch (NumberFormatException e) {
537 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
538 String.format("Badly formatted version number: %s", softwareVersion));
541 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
542 String.format("Can't parse software version: %s", softwareVersion));
545 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Software version is null");
550 private boolean checkApiVersion(@Nullable String apiVersion) {
551 logger.debug("Bridge: Zoneminder API version is {}", apiVersion);
552 if (apiVersion != null) {
553 String[] versionParts = apiVersion.split("\\.");
554 if (versionParts.length >= 2) {
556 int versionMajor = Integer.parseInt(versionParts[0]);
557 if (versionMajor >= 2) {
558 logger.debug("Bridge: Zoneminder API version check OK");
561 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
562 .format("Requires API version >= 2.0. This Zoneminder is API version {}", apiVersion));
564 } catch (NumberFormatException e) {
565 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
566 String.format("Badly formatted API version: %s", apiVersion));
569 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
570 String.format("Can't parse API version: %s", apiVersion));
573 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "API version is null");
578 @SuppressWarnings("null")
579 private void refreshMonitors() {
580 List<Monitor> monitors = getMonitors();
581 savedMonitors = monitors;
582 for (Monitor monitor : monitors) {
583 ZmMonitorHandler handler = monitorHandlers.get(monitor.getId());
584 if (handler != null) {
585 handler.updateStatus(monitor);
590 private void scheduleRefreshJob() {
591 logger.debug("Bridge: Scheduling monitors refresh job");
593 refreshMonitorsJob = scheduler.scheduleWithFixedDelay(this::refreshMonitors,
594 MONITOR_REFRESH_STARTUP_DELAY_SECONDS, monitorRefreshInterval, TimeUnit.SECONDS);
597 private void cancelRefreshJob() {
598 Future<?> localRefreshThermostatsJob = refreshMonitorsJob;
599 if (localRefreshThermostatsJob != null) {
600 localRefreshThermostatsJob.cancel(true);
601 logger.debug("Bridge: Canceling monitors refresh job");
602 refreshMonitorsJob = null;