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.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 = "/".equals(config.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));
322 // Update state options
323 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_IMAGE_MONITOR_ID),
325 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_VIDEO_MONITOR_ID),
327 // Only update alarm and event info for monitors whose handlers are initialized
328 Set<String> ids = monitorHandlers.keySet();
329 for (Monitor m : monitorList) {
330 if (ids.contains(m.getId())) {
331 m.setState(getState(m.getId()));
332 m.setLastEvent(getLastEvent(m.getId()));
336 } catch (JsonSyntaxException e) {
337 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
342 private void extractEventCounts(Monitor monitor, MonitorItemDTO monitorItemDTO) {
344 * The Zoneminder API changed in version 1.36.x such that the event counts moved from the
345 * monitor object to a new event summary object. Therefore, if the event summary object
346 * exists in the JSON response, pull the event counts from that object, otherwise get the
347 * counts from the monitor object.
349 if (monitorItemDTO.eventSummary != null) {
350 EventSummaryDTO eventSummaryDTO = monitorItemDTO.eventSummary;
351 monitor.setHourEvents(eventSummaryDTO.hourEvents);
352 monitor.setDayEvents(eventSummaryDTO.dayEvents);
353 monitor.setWeekEvents(eventSummaryDTO.weekEvents);
354 monitor.setMonthEvents(eventSummaryDTO.monthEvents);
355 monitor.setTotalEvents(eventSummaryDTO.totalEvents);
357 MonitorDTO monitorDTO = monitorItemDTO.monitor;
358 monitor.setHourEvents(monitorDTO.hourEvents);
359 monitor.setDayEvents(monitorDTO.dayEvents);
360 monitor.setWeekEvents(monitorDTO.weekEvents);
361 monitor.setMonthEvents(monitorDTO.monthEvents);
362 monitor.setTotalEvents(monitorDTO.totalEvents);
366 @SuppressWarnings("null")
367 private @Nullable Event getLastEvent(String id) {
368 if (!zmAuth.isAuthorized()) {
372 List<String> parameters = new ArrayList<>();
373 parameters.add("sort=StartTime");
374 parameters.add("direction=desc");
375 parameters.add("limit=1");
376 String response = executeGet(buildUrlWithParameters(
377 String.format("/api/events/index/MonitorId:%s/Name!=:New%%20Event.json", id), parameters));
378 EventsDTO events = GSON.fromJson(response, EventsDTO.class);
379 if (events != null && events.eventsList != null && events.eventsList.size() == 1) {
380 EventDTO e = events.eventsList.get(0).event;
381 Event event = new Event(e.eventId, e.name, e.cause, e.notes, e.startTime, e.endTime);
382 event.setFrames(e.frames);
383 event.setAlarmFrames(e.alarmFrames);
384 event.setLength(e.length);
387 } catch (JsonSyntaxException e) {
388 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
393 private @Nullable VersionDTO getVersion() {
394 if (!zmAuth.isAuthorized()) {
397 VersionDTO version = null;
399 String response = executeGet(buildUrl("/api/host/getVersion.json"));
400 version = GSON.fromJson(response, VersionDTO.class);
401 } catch (JsonSyntaxException e) {
402 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
407 private void setAlarm(String url) {
411 @SuppressWarnings("null")
412 private MonitorState getState(String id) {
413 if (!zmAuth.isAuthorized()) {
414 return MonitorState.UNKNOWN;
417 String response = executeGet(buildUrl(String.format("/api/monitors/alarm/id:%s/command:status.json", id)));
418 MonitorStateDTO monitorState = GSON.fromJson(response, MonitorStateDTO.class);
419 if (monitorState != null) {
420 MonitorState state = monitorState.state;
421 return state != null ? state : MonitorState.UNKNOWN;
423 } catch (JsonSyntaxException e) {
424 logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
426 return MonitorState.UNKNOWN;
429 public @Nullable String executeGet(String url) {
431 long startTime = System.currentTimeMillis();
432 String response = HttpUtil.executeUrl("GET", url, API_TIMEOUT_MSEC);
433 logger.trace("Bridge: Http GET of '{}' returned '{}' in {} ms", url, response,
434 System.currentTimeMillis() - startTime);
436 } catch (IOException e) {
437 logger.debug("Bridge: IOException on GET request, url='{}': {}", url, e.getMessage());
442 private @Nullable String executePost(String url, String content) {
443 return executePost(url, content, "application/x-www-form-urlencoded");
446 public @Nullable String executePost(String url, String content, String contentType) {
447 try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
448 long startTime = System.currentTimeMillis();
449 String response = HttpUtil.executeUrl("POST", url, inputStream, contentType, API_TIMEOUT_MSEC);
450 logger.trace("Bridge: Http POST content '{}' to '{}' returned: {} in {} ms", content, url, response,
451 System.currentTimeMillis() - startTime);
453 } catch (IOException e) {
454 logger.debug("Bridge: IOException on POST request, url='{}': {}", url, e.getMessage());
459 public String buildLoginUrl() {
460 return buildBaseUrl(LOGIN_PATH).toString();
463 public String buildLoginUrl(String tokenParameter) {
464 StringBuilder sb = buildBaseUrl(LOGIN_PATH);
465 sb.append(tokenParameter);
466 return sb.toString();
469 private String buildStreamUrl(String id, String streamType) {
470 List<String> parameters = new ArrayList<>();
471 parameters.add(String.format("mode=%s", streamType));
472 parameters.add(String.format("monitor=%s", id));
473 return buildUrlWithParameters("/cgi-bin/zms", parameters);
476 private String buildUrl(String path) {
477 return buildUrlWithParameters(path, EMPTY_LIST);
480 private String buildUrlWithParameters(String path, List<String> parameters) {
481 StringBuilder sb = buildBaseUrl(path);
483 for (String parameter : parameters) {
484 sb.append(joiner).append(parameter);
487 if (zmAuth.usingAuthorization()) {
488 sb.append(joiner).append("token=").append(zmAuth.getAccessToken());
490 return sb.toString();
493 private StringBuilder buildBaseUrl(String path) {
494 StringBuilder sb = new StringBuilder();
495 sb.append(useSSL ? "https://" : "http://");
497 if (portNumber != null) {
498 sb.append(":").append(portNumber);
505 private boolean isMonitorIdValid(String id) {
506 return savedMonitors.stream().filter(monitor -> id.equals(monitor.getId())).findAny().isPresent();
509 private boolean isHostValid() {
510 logger.debug("Bridge: Checking for valid Zoneminder host: {}", host);
511 VersionDTO version = getVersion();
512 if (version != null) {
513 if (checkSoftwareVersion(version.version) && checkApiVersion(version.apiVersion)) {
517 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Can't get version information");
522 private boolean checkSoftwareVersion(@Nullable String softwareVersion) {
523 logger.debug("Bridge: Zoneminder software version is {}", softwareVersion);
524 if (softwareVersion != null) {
525 String[] versionParts = softwareVersion.split("\\.");
526 if (versionParts.length >= 2) {
528 int versionMajor = Integer.parseInt(versionParts[0]);
529 int versionMinor = Integer.parseInt(versionParts[1]);
530 if (versionMajor == 1 && versionMinor >= 34) {
531 logger.debug("Bridge: Zoneminder software version check OK");
534 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
535 .format("Current Zoneminder version: %s. Requires version >= 1.34.0", softwareVersion));
537 } catch (NumberFormatException e) {
538 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
539 String.format("Badly formatted version number: %s", softwareVersion));
542 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
543 String.format("Can't parse software version: %s", softwareVersion));
546 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Software version is null");
551 private boolean checkApiVersion(@Nullable String apiVersion) {
552 logger.debug("Bridge: Zoneminder API version is {}", apiVersion);
553 if (apiVersion != null) {
554 String[] versionParts = apiVersion.split("\\.");
555 if (versionParts.length >= 2) {
557 int versionMajor = Integer.parseInt(versionParts[0]);
558 if (versionMajor >= 2) {
559 logger.debug("Bridge: Zoneminder API version check OK");
562 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
563 .format("Requires API version >= 2.0. This Zoneminder is API version {}", apiVersion));
565 } catch (NumberFormatException e) {
566 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
567 String.format("Badly formatted API version: %s", apiVersion));
570 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
571 String.format("Can't parse API version: %s", apiVersion));
574 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "API version is null");
579 @SuppressWarnings("null")
580 private void refreshMonitors() {
581 List<Monitor> monitors = getMonitors();
582 savedMonitors = monitors;
583 for (Monitor monitor : monitors) {
584 ZmMonitorHandler handler = monitorHandlers.get(monitor.getId());
585 if (handler != null) {
586 handler.updateStatus(monitor);
591 private void scheduleRefreshJob() {
592 logger.debug("Bridge: Scheduling monitors refresh job");
594 refreshMonitorsJob = scheduler.scheduleWithFixedDelay(this::refreshMonitors,
595 MONITOR_REFRESH_STARTUP_DELAY_SECONDS, monitorRefreshInterval, TimeUnit.SECONDS);
598 private void cancelRefreshJob() {
599 Future<?> localRefreshThermostatsJob = refreshMonitorsJob;
600 if (localRefreshThermostatsJob != null) {
601 localRefreshThermostatsJob.cancel(true);
602 logger.debug("Bridge: Canceling monitors refresh job");
603 refreshMonitorsJob = null;