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;
31 import java.util.concurrent.atomic.AtomicInteger;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.eclipse.jetty.client.HttpClient;
36 import org.eclipse.jetty.client.api.ContentResponse;
37 import org.eclipse.jetty.client.api.Request;
38 import org.eclipse.jetty.http.HttpHeader;
39 import org.eclipse.jetty.http.HttpMethod;
40 import org.eclipse.jetty.http.HttpStatus;
41 import org.openhab.binding.zoneminder.internal.ZmStateDescriptionOptionsProvider;
42 import org.openhab.binding.zoneminder.internal.config.ZmBridgeConfig;
43 import org.openhab.binding.zoneminder.internal.discovery.MonitorDiscoveryService;
44 import org.openhab.binding.zoneminder.internal.dto.EventDTO;
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 REFRESH_INTERVAL_SECONDS = 1;
85 private static final int REFRESH_STARTUP_DELAY_SECONDS = 3;
87 private static final int MONITORS_INTERVAL_SECONDS = 5;
88 private static final int MONITORS_INITIAL_DELAY_SECONDS = 3;
90 private static final int DISCOVERY_INTERVAL_SECONDS = 300;
91 private static final int DISCOVERY_INITIAL_DELAY_SECONDS = 10;
93 private static final int API_TIMEOUT_MSEC = 10000;
95 private static final String LOGIN_PATH = "/api/host/login.json";
97 private static final String STREAM_IMAGE = "single";
98 private static final String STREAM_VIDEO = "jpeg";
100 private static final List<String> EMPTY_LIST = Collections.emptyList();
102 private static final Gson GSON = new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create();
104 private final Logger logger = LoggerFactory.getLogger(ZmBridgeHandler.class);
106 private @Nullable Future<?> refreshMonitorsJob;
107 private final AtomicInteger monitorsCounter = new AtomicInteger();
109 private @Nullable MonitorDiscoveryService discoveryService;
110 private final AtomicInteger discoveryCounter = new AtomicInteger();
112 private List<Monitor> savedMonitors = new ArrayList<>();
114 private String host = "";
115 private boolean useSSL;
116 private @Nullable String portNumber;
117 private String urlPath = DEFAULT_URL_PATH;
118 private int monitorsInterval;
119 private int discoveryInterval;
120 private boolean discoveryEnabled;
121 private int defaultAlarmDuration;
122 private @Nullable Integer defaultImageRefreshInterval;
124 private final HttpClient httpClient;
125 private final ZmStateDescriptionOptionsProvider stateDescriptionProvider;
127 private ZmAuth zmAuth;
129 // Maintain mapping of handler and monitor id
130 private final Map<String, ZmMonitorHandler> monitorHandlers = new ConcurrentHashMap<>();
132 public ZmBridgeHandler(Bridge thing, HttpClient httpClient,
133 ZmStateDescriptionOptionsProvider stateDescriptionProvider) {
135 this.httpClient = httpClient;
136 this.stateDescriptionProvider = stateDescriptionProvider;
137 // Default to use no authentication
138 zmAuth = new ZmAuth(this);
142 public void initialize() {
143 ZmBridgeConfig config = getConfigAs(ZmBridgeConfig.class);
146 value = config.refreshInterval;
147 monitorsInterval = value == null ? MONITORS_INTERVAL_SECONDS : value;
149 value = config.discoveryInterval;
150 discoveryInterval = value == null ? DISCOVERY_INTERVAL_SECONDS : value;
152 value = config.defaultAlarmDuration;
153 defaultAlarmDuration = value == null ? DEFAULT_ALARM_DURATION_SECONDS : value;
155 defaultImageRefreshInterval = config.defaultImageRefreshInterval;
157 discoveryEnabled = config.discoveryEnabled == null ? false : config.discoveryEnabled.booleanValue();
160 useSSL = config.useSSL.booleanValue();
161 portNumber = config.portNumber != null ? Integer.toString(config.portNumber) : null;
162 urlPath = config.urlPath;
164 // If user and password are configured, then use Zoneminder authentication
165 if (config.user != null && config.pass != null) {
166 zmAuth = new ZmAuth(this, config.user, config.pass);
169 updateStatus(ThingStatus.ONLINE);
170 scheduleRefreshJob();
175 public void dispose() {
180 public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
181 String monitorId = (String) childThing.getConfiguration().get(CONFIG_MONITOR_ID);
182 monitorHandlers.put(monitorId, (ZmMonitorHandler) childHandler);
183 logger.debug("Bridge: Monitor handler was initialized for {} with id {}", childThing.getUID(), monitorId);
187 public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
188 String monitorId = (String) childThing.getConfiguration().get(CONFIG_MONITOR_ID);
189 monitorHandlers.remove(monitorId);
190 logger.debug("Bridge: Monitor handler was disposed for {} with id {}", childThing.getUID(), monitorId);
194 public void handleCommand(ChannelUID channelUID, Command command) {
195 switch (channelUID.getId()) {
196 case CHANNEL_IMAGE_MONITOR_ID:
197 handleMonitorIdCommand(command, CHANNEL_IMAGE_MONITOR_ID, CHANNEL_IMAGE_URL, STREAM_IMAGE);
199 case CHANNEL_VIDEO_MONITOR_ID:
200 handleMonitorIdCommand(command, CHANNEL_VIDEO_MONITOR_ID, CHANNEL_VIDEO_URL, STREAM_VIDEO);
205 private void handleMonitorIdCommand(Command command, String monitorIdChannelId, String urlChannelId, String type) {
206 if (command instanceof RefreshType || command == OnOffType.OFF) {
207 updateState(monitorIdChannelId, UnDefType.UNDEF);
208 updateState(urlChannelId, UnDefType.UNDEF);
209 } else if (command instanceof StringType) {
210 String id = command.toString();
211 if (isMonitorIdValid(id)) {
212 updateState(urlChannelId, new StringType(buildStreamUrl(id, type)));
214 updateState(monitorIdChannelId, UnDefType.UNDEF);
215 updateState(urlChannelId, UnDefType.UNDEF);
221 public Collection<Class<? extends ThingHandlerService>> getServices() {
222 return Collections.singleton(MonitorDiscoveryService.class);
225 public void setDiscoveryService(MonitorDiscoveryService discoveryService) {
226 this.discoveryService = discoveryService;
229 public boolean isDiscoveryEnabled() {
230 return discoveryEnabled;
233 public Integer getDefaultAlarmDuration() {
234 return defaultAlarmDuration;
237 public @Nullable Integer getDefaultImageRefreshInterval() {
238 return defaultImageRefreshInterval;
241 public List<Monitor> getSavedMonitors() {
242 return savedMonitors;
245 public Gson getGson() {
249 public void setFunction(String id, MonitorFunction function) {
250 if (!zmAuth.isAuthorized()) {
253 logger.debug("Bridge: Setting monitor {} function to {}", id, function);
254 executePost(buildUrl(String.format("/api/monitors/%s.json", id)),
255 String.format("Monitor[Function]=%s", function.toString()));
258 public void setEnabled(String id, OnOffType enabled) {
259 if (!zmAuth.isAuthorized()) {
262 logger.debug("Bridge: Setting monitor {} to {}", id, enabled);
263 executePost(buildUrl(String.format("/api/monitors/%s.json", id)),
264 String.format("Monitor[Enabled]=%s", enabled == OnOffType.ON ? "1" : "0"));
267 public void setAlarmOn(String id) {
268 if (!zmAuth.isAuthorized()) {
271 logger.debug("Bridge: Turning alarm ON for monitor {}", id);
272 setAlarm(buildUrl(String.format("/api/monitors/alarm/id:%s/command:on.json", id)));
275 public void setAlarmOff(String id) {
276 if (!zmAuth.isAuthorized()) {
279 logger.debug("Bridge: Turning alarm OFF for monitor {}", id);
280 setAlarm(buildUrl(String.format("/api/monitors/alarm/id:%s/command:off.json", id)));
283 public @Nullable RawType getImage(String id, @Nullable Integer imageRefreshIntervalSeconds) {
284 Integer localRefreshInterval = imageRefreshIntervalSeconds;
285 if (localRefreshInterval == null || localRefreshInterval.intValue() < 1 || !zmAuth.isAuthorized()) {
288 // Call should timeout just before the refresh interval
289 int timeout = Math.min((localRefreshInterval * 1000) - 500, API_TIMEOUT_MSEC);
290 Request request = httpClient.newRequest(buildStreamUrl(id, STREAM_IMAGE));
291 request.method(HttpMethod.GET);
292 request.timeout(timeout, TimeUnit.MILLISECONDS);
296 ContentResponse response = request.send();
297 if (response.getStatus() == HttpStatus.OK_200) {
298 RawType image = new RawType(response.getContent(), response.getHeaders().get(HttpHeader.CONTENT_TYPE));
301 errorMsg = String.format("HTTP GET failed: %d, %s", response.getStatus(), response.getReason());
303 } catch (TimeoutException e) {
304 errorMsg = String.format("TimeoutException: Call to Zoneminder API timed out after {} msec", timeout);
305 } catch (ExecutionException e) {
306 errorMsg = String.format("ExecutionException: %s", e.getMessage());
307 } catch (InterruptedException e) {
308 errorMsg = String.format("InterruptedException: %s", e.getMessage());
309 Thread.currentThread().interrupt();
311 logger.debug("{}", errorMsg);
315 @SuppressWarnings("null")
316 private synchronized List<Monitor> getMonitors() {
317 List<Monitor> monitorList = new ArrayList<>();
318 if (!zmAuth.isAuthorized()) {
322 String response = executeGet(buildUrl("/api/monitors.json"));
323 MonitorsDTO monitors = GSON.fromJson(response, MonitorsDTO.class);
324 if (monitors != null && monitors.monitorItems != null) {
325 List<StateOption> options = new ArrayList<>();
326 for (MonitorItemDTO monitorItem : monitors.monitorItems) {
327 MonitorDTO m = monitorItem.monitor;
328 MonitorStatusDTO mStatus = monitorItem.monitorStatus;
329 if (m != null && mStatus != null) {
330 Monitor monitor = new Monitor(m.id, m.name, m.function, m.enabled, mStatus.status);
331 monitor.setHourEvents(m.hourEvents);
332 monitor.setDayEvents(m.dayEvents);
333 monitor.setWeekEvents(m.weekEvents);
334 monitor.setMonthEvents(m.monthEvents);
335 monitor.setTotalEvents(m.totalEvents);
336 monitor.setImageUrl(buildStreamUrl(m.id, STREAM_IMAGE));
337 monitor.setVideoUrl(buildStreamUrl(m.id, STREAM_VIDEO));
338 monitorList.add(monitor);
339 options.add(new StateOption(m.id, "Monitor " + m.id));
341 stateDescriptionProvider
342 .setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_IMAGE_MONITOR_ID), options);
343 stateDescriptionProvider
344 .setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_VIDEO_MONITOR_ID), options);
346 // Only update alarm and event info for monitors whose handlers are initialized
347 Set<String> ids = monitorHandlers.keySet();
348 for (Monitor m : monitorList) {
349 if (ids.contains(m.getId())) {
350 m.setState(getState(m.getId()));
351 m.setLastEvent(getLastEvent(m.getId()));
355 } catch (JsonSyntaxException e) {
356 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
361 @SuppressWarnings("null")
362 private @Nullable Event getLastEvent(String id) {
363 if (!zmAuth.isAuthorized()) {
367 List<String> parameters = new ArrayList<>();
368 parameters.add("sort=StartTime");
369 parameters.add("direction=desc");
370 parameters.add("limit=1");
371 String response = executeGet(
372 buildUrlWithParameters(String.format("/api/events/index/MonitorId:%s.json", id), parameters));
373 EventsDTO events = GSON.fromJson(response, EventsDTO.class);
374 if (events != null && events.eventsList != null && events.eventsList.size() == 1) {
375 EventDTO e = events.eventsList.get(0).event;
376 Event event = new Event(e.eventId, e.name, e.cause, e.notes, e.startTime, e.endTime);
377 event.setFrames(e.frames);
378 event.setAlarmFrames(e.alarmFrames);
379 event.setLength(e.length);
382 } catch (JsonSyntaxException e) {
383 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
388 private @Nullable VersionDTO getVersion() {
389 if (!zmAuth.isAuthorized()) {
392 VersionDTO version = null;
394 String response = executeGet(buildUrl("/api/host/getVersion.json"));
395 version = GSON.fromJson(response, VersionDTO.class);
396 } catch (JsonSyntaxException e) {
397 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
402 private void setAlarm(String url) {
406 @SuppressWarnings("null")
407 private MonitorState getState(String id) {
408 if (!zmAuth.isAuthorized()) {
409 return MonitorState.UNKNOWN;
412 String response = executeGet(buildUrl(String.format("/api/monitors/alarm/id:%s/command:status.json", id)));
413 MonitorStateDTO monitorState = GSON.fromJson(response, MonitorStateDTO.class);
414 if (monitorState != null) {
415 MonitorState state = monitorState.state;
416 return state != null ? state : MonitorState.UNKNOWN;
418 } catch (JsonSyntaxException e) {
419 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
421 return MonitorState.UNKNOWN;
424 public @Nullable String executeGet(String url) {
426 long startTime = System.currentTimeMillis();
427 String response = HttpUtil.executeUrl("GET", url, API_TIMEOUT_MSEC);
428 logger.trace("Bridge: Http GET of '{}' returned '{}' in {} ms", url, response,
429 System.currentTimeMillis() - startTime);
431 } catch (IOException e) {
432 logger.debug("Bridge: IOException on GET request, url='{}': {}", url, e.getMessage());
437 private @Nullable String executePost(String url, String content) {
438 return executePost(url, content, "application/x-www-form-urlencoded");
441 public @Nullable String executePost(String url, String content, String contentType) {
442 try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
443 long startTime = System.currentTimeMillis();
444 String response = HttpUtil.executeUrl("POST", url, inputStream, contentType, API_TIMEOUT_MSEC);
445 logger.trace("Bridge: Http POST content '{}' to '{}' returned: {} in {} ms", content, url, response,
446 System.currentTimeMillis() - startTime);
448 } catch (IOException e) {
449 logger.debug("Bridge: IOException on POST request, url='{}': {}", url, e.getMessage());
454 public String buildLoginUrl() {
455 return buildBaseUrl(LOGIN_PATH).toString();
458 public String buildLoginUrl(String tokenParameter) {
459 StringBuilder sb = buildBaseUrl(LOGIN_PATH);
460 sb.append(tokenParameter);
461 return sb.toString();
464 private String buildStreamUrl(String id, String streamType) {
465 List<String> parameters = new ArrayList<>();
466 parameters.add(String.format("mode=%s", streamType));
467 parameters.add(String.format("monitor=%s", id));
468 return buildUrlWithParameters("/cgi-bin/zms", parameters);
471 private String buildUrl(String path) {
472 return buildUrlWithParameters(path, EMPTY_LIST);
475 private String buildUrlWithParameters(String path, List<String> parameters) {
476 StringBuilder sb = buildBaseUrl(path);
478 for (String parameter : parameters) {
479 sb.append(joiner).append(parameter);
482 if (zmAuth.usingAuthorization()) {
483 sb.append(joiner).append("token=").append(zmAuth.getAccessToken());
485 return sb.toString();
488 private StringBuilder buildBaseUrl(String path) {
489 StringBuilder sb = new StringBuilder();
490 sb.append(useSSL ? "https://" : "http://");
492 if (portNumber != null) {
493 sb.append(":").append(portNumber);
500 private boolean isMonitorIdValid(String id) {
501 return savedMonitors.stream().filter(monitor -> id.equals(monitor.getId())).findAny().isPresent();
504 private boolean isHostValid() {
505 logger.debug("Bridge: Checking for valid Zoneminder host: {}", host);
506 VersionDTO version = getVersion();
507 if (version != null) {
508 if (checkSoftwareVersion(version.version) && checkApiVersion(version.apiVersion)) {
512 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Can't get version information");
517 private boolean checkSoftwareVersion(@Nullable String softwareVersion) {
518 logger.debug("Bridge: Zoneminder software version is {}", softwareVersion);
519 if (softwareVersion != null) {
520 String[] versionParts = softwareVersion.split("\\.");
521 if (versionParts.length >= 2) {
523 int versionMajor = Integer.parseInt(versionParts[0]);
524 int versionMinor = Integer.parseInt(versionParts[1]);
525 if (versionMajor == 1 && versionMinor >= 34) {
526 logger.debug("Bridge: Zoneminder software version check OK");
529 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
530 .format("Current Zoneminder version: %s. Requires version >= 1.34.0", softwareVersion));
532 } catch (NumberFormatException e) {
533 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
534 String.format("Badly formatted version number: %s", softwareVersion));
537 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
538 String.format("Can't parse software version: %s", softwareVersion));
541 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Software version is null");
546 private boolean checkApiVersion(@Nullable String apiVersion) {
547 logger.debug("Bridge: Zoneminder API version is {}", apiVersion);
548 if (apiVersion != null) {
549 String[] versionParts = apiVersion.split("\\.");
550 if (versionParts.length >= 2) {
552 int versionMajor = Integer.parseInt(versionParts[0]);
553 if (versionMajor >= 2) {
554 logger.debug("Bridge: Zoneminder API version check OK");
557 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
558 .format("Requires API version >= 2.0. This Zoneminder is API version {}", apiVersion));
560 } catch (NumberFormatException e) {
561 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
562 String.format("Badly formatted API version: %s", apiVersion));
565 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
566 String.format("Can't parse API version: %s", apiVersion));
569 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "API version is null");
575 * The refresh job is executed every second
576 * - updates the monitor handlers every monitorsInterval seconds, and
577 * - runs the monitor discovery every discoveryInterval seconds
579 private void refresh() {
584 @SuppressWarnings("null")
585 private void refreshMonitors() {
586 if (monitorsCounter.getAndDecrement() == 0) {
587 monitorsCounter.set(monitorsInterval);
588 List<Monitor> monitors = getMonitors();
589 savedMonitors = monitors;
590 for (Monitor monitor : monitors) {
591 ZmMonitorHandler handler = monitorHandlers.get(monitor.getId());
592 if (handler != null) {
593 handler.updateStatus(monitor);
599 private void discoverMonitors() {
600 if (isDiscoveryEnabled()) {
601 if (discoveryCounter.getAndDecrement() == 0) {
602 discoveryCounter.set(discoveryInterval);
603 MonitorDiscoveryService localDiscoveryService = discoveryService;
604 if (localDiscoveryService != null) {
605 logger.trace("Bridge: Running monitor discovery");
606 localDiscoveryService.startBackgroundDiscovery();
612 private void scheduleRefreshJob() {
613 logger.debug("Bridge: Scheduling monitors refresh job");
615 monitorsCounter.set(MONITORS_INITIAL_DELAY_SECONDS);
616 discoveryCounter.set(DISCOVERY_INITIAL_DELAY_SECONDS);
617 refreshMonitorsJob = scheduler.scheduleWithFixedDelay(this::refresh, REFRESH_STARTUP_DELAY_SECONDS,
618 REFRESH_INTERVAL_SECONDS, TimeUnit.SECONDS);
621 private void cancelRefreshJob() {
622 Future<?> localRefreshThermostatsJob = refreshMonitorsJob;
623 if (localRefreshThermostatsJob != null) {
624 localRefreshThermostatsJob.cancel(true);
625 logger.debug("Bridge: Canceling monitors refresh job");