]> git.basschouten.com Git - openhab-addons.git/blob
5f6b8e88625c8ab182c1938d04b133d9d80a1180
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.zoneminder.internal.handler;
14
15 import static org.openhab.binding.zoneminder.internal.ZmBindingConstants.*;
16
17 import java.io.ByteArrayInputStream;
18 import java.io.IOException;
19 import java.net.URLEncoder;
20 import java.nio.charset.Charset;
21 import java.nio.charset.StandardCharsets;
22 import java.util.ArrayList;
23 import java.util.Collection;
24 import java.util.Collections;
25 import java.util.List;
26 import java.util.Map;
27 import java.util.Set;
28 import java.util.concurrent.ConcurrentHashMap;
29 import java.util.concurrent.ExecutionException;
30 import java.util.concurrent.Future;
31 import java.util.concurrent.TimeUnit;
32 import java.util.concurrent.TimeoutException;
33
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.eclipse.jetty.client.HttpClient;
37 import org.eclipse.jetty.client.api.ContentResponse;
38 import org.eclipse.jetty.client.api.Request;
39 import org.eclipse.jetty.http.HttpHeader;
40 import org.eclipse.jetty.http.HttpMethod;
41 import org.eclipse.jetty.http.HttpStatus;
42 import org.openhab.binding.zoneminder.internal.ZmStateDescriptionOptionsProvider;
43 import org.openhab.binding.zoneminder.internal.config.ZmBridgeConfig;
44 import org.openhab.binding.zoneminder.internal.discovery.MonitorDiscoveryService;
45 import org.openhab.binding.zoneminder.internal.dto.EventDTO;
46 import org.openhab.binding.zoneminder.internal.dto.EventSummaryDTO;
47 import org.openhab.binding.zoneminder.internal.dto.EventsDTO;
48 import org.openhab.binding.zoneminder.internal.dto.MonitorDTO;
49 import org.openhab.binding.zoneminder.internal.dto.MonitorItemDTO;
50 import org.openhab.binding.zoneminder.internal.dto.MonitorStateDTO;
51 import org.openhab.binding.zoneminder.internal.dto.MonitorStatusDTO;
52 import org.openhab.binding.zoneminder.internal.dto.MonitorsDTO;
53 import org.openhab.binding.zoneminder.internal.dto.RunStateDTO;
54 import org.openhab.binding.zoneminder.internal.dto.RunStateDTO.RunState;
55 import org.openhab.binding.zoneminder.internal.dto.RunStatesDTO;
56 import org.openhab.binding.zoneminder.internal.dto.VersionDTO;
57 import org.openhab.core.io.net.http.HttpUtil;
58 import org.openhab.core.library.types.OnOffType;
59 import org.openhab.core.library.types.RawType;
60 import org.openhab.core.library.types.StringType;
61 import org.openhab.core.thing.Bridge;
62 import org.openhab.core.thing.ChannelUID;
63 import org.openhab.core.thing.Thing;
64 import org.openhab.core.thing.ThingStatus;
65 import org.openhab.core.thing.ThingStatusDetail;
66 import org.openhab.core.thing.binding.BaseBridgeHandler;
67 import org.openhab.core.thing.binding.ThingHandler;
68 import org.openhab.core.thing.binding.ThingHandlerService;
69 import org.openhab.core.types.Command;
70 import org.openhab.core.types.RefreshType;
71 import org.openhab.core.types.StateOption;
72 import org.openhab.core.types.UnDefType;
73 import org.slf4j.Logger;
74 import org.slf4j.LoggerFactory;
75
76 import com.google.gson.Gson;
77 import com.google.gson.GsonBuilder;
78 import com.google.gson.JsonSyntaxException;
79
80 /**
81  * The {@link ZmBridgeHandler} represents the Zoneminder server. It handles all communication
82  * with the Zoneminder server.
83  *
84  * @author Mark Hilbush - Initial contribution
85  */
86 @NonNullByDefault
87 public class ZmBridgeHandler extends BaseBridgeHandler {
88
89     private static final int MONITOR_REFRESH_INTERVAL_SECONDS = 10;
90     private static final int MONITOR_REFRESH_STARTUP_DELAY_SECONDS = 5;
91
92     private static final int API_TIMEOUT_MSEC = 10000;
93
94     private static final String LOGIN_PATH = "/api/host/login.json";
95
96     private static final String STREAM_IMAGE = "single";
97     private static final String STREAM_VIDEO = "jpeg";
98
99     private static final List<String> EMPTY_LIST = Collections.emptyList();
100
101     private static final Gson GSON = new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create();
102
103     private final Logger logger = LoggerFactory.getLogger(ZmBridgeHandler.class);
104
105     private @Nullable Future<?> refreshMonitorsJob;
106
107     private List<Monitor> savedMonitors = new ArrayList<>();
108
109     private String host = "";
110     private boolean useSSL;
111     private @Nullable String portNumber;
112     private String urlPath = DEFAULT_URL_PATH;
113     private int monitorRefreshInterval;
114     private boolean backgroundDiscoveryEnabled;
115     private int defaultAlarmDuration;
116     private @Nullable Integer defaultImageRefreshInterval;
117
118     private final HttpClient httpClient;
119     private final ZmStateDescriptionOptionsProvider stateDescriptionProvider;
120
121     private ZmAuth zmAuth;
122
123     // Maintain mapping of handler and monitor id
124     private final Map<String, ZmMonitorHandler> monitorHandlers = new ConcurrentHashMap<>();
125
126     public ZmBridgeHandler(Bridge thing, HttpClient httpClient,
127             ZmStateDescriptionOptionsProvider stateDescriptionProvider) {
128         super(thing);
129         this.httpClient = httpClient;
130         this.stateDescriptionProvider = stateDescriptionProvider;
131         // Default to use no authentication
132         zmAuth = new ZmAuth(this);
133     }
134
135     @Override
136     public void initialize() {
137         ZmBridgeConfig config = getConfigAs(ZmBridgeConfig.class);
138
139         Integer value;
140         value = config.refreshInterval;
141         monitorRefreshInterval = value == null ? MONITOR_REFRESH_INTERVAL_SECONDS : value;
142
143         value = config.defaultAlarmDuration;
144         defaultAlarmDuration = value == null ? DEFAULT_ALARM_DURATION_SECONDS : value;
145
146         defaultImageRefreshInterval = config.defaultImageRefreshInterval;
147
148         backgroundDiscoveryEnabled = config.discoveryEnabled;
149         logger.debug("Bridge: Background discovery is {}", backgroundDiscoveryEnabled ? "ENABLED" : "DISABLED");
150
151         host = config.host;
152         useSSL = config.useSSL.booleanValue();
153         portNumber = config.portNumber != null ? Integer.toString(config.portNumber) : null;
154         urlPath = "/".equals(config.urlPath) ? "" : config.urlPath;
155
156         // If user and password are configured, then use Zoneminder authentication
157         if (config.user != null && config.pass != null) {
158             zmAuth = new ZmAuth(this, config.user, config.pass);
159         }
160         if (isHostValid()) {
161             updateStatus(ThingStatus.ONLINE);
162             scheduleRefreshJob();
163         }
164     }
165
166     @Override
167     public void dispose() {
168         cancelRefreshJob();
169     }
170
171     @Override
172     public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
173         String monitorId = (String) childThing.getConfiguration().get(CONFIG_MONITOR_ID);
174         monitorHandlers.put(monitorId, (ZmMonitorHandler) childHandler);
175         logger.debug("Bridge: Monitor handler was initialized for {} with id {}", childThing.getUID(), monitorId);
176     }
177
178     @Override
179     public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
180         String monitorId = (String) childThing.getConfiguration().get(CONFIG_MONITOR_ID);
181         monitorHandlers.remove(monitorId);
182         logger.debug("Bridge: Monitor handler was disposed for {} with id {}", childThing.getUID(), monitorId);
183     }
184
185     @Override
186     public void handleCommand(ChannelUID channelUID, Command command) {
187         switch (channelUID.getId()) {
188             case CHANNEL_IMAGE_MONITOR_ID:
189                 handleMonitorIdCommand(command, CHANNEL_IMAGE_MONITOR_ID, CHANNEL_IMAGE_URL, STREAM_IMAGE);
190                 break;
191             case CHANNEL_VIDEO_MONITOR_ID:
192                 handleMonitorIdCommand(command, CHANNEL_VIDEO_MONITOR_ID, CHANNEL_VIDEO_URL, STREAM_VIDEO);
193                 break;
194             case CHANNEL_RUN_STATE:
195                 if (command instanceof StringType) {
196                     changeRunState(command);
197                 }
198                 break;
199         }
200     }
201
202     private void handleMonitorIdCommand(Command command, String monitorIdChannelId, String urlChannelId, String type) {
203         if (command instanceof RefreshType || command == OnOffType.OFF) {
204             updateState(monitorIdChannelId, UnDefType.UNDEF);
205             updateState(urlChannelId, UnDefType.UNDEF);
206         } else if (command instanceof StringType) {
207             String id = command.toString();
208             if (isMonitorIdValid(id)) {
209                 updateState(urlChannelId, new StringType(buildStreamUrl(id, type)));
210             } else {
211                 updateState(monitorIdChannelId, UnDefType.UNDEF);
212                 updateState(urlChannelId, UnDefType.UNDEF);
213             }
214         }
215     }
216
217     private void changeRunState(Command command) {
218         logger.debug("Bridge: Change run state to {}", command);
219         executeGet(buildUrl(String.format("/api/states/change/%s.json",
220                 URLEncoder.encode(command.toString(), Charset.defaultCharset()))));
221     }
222
223     @Override
224     public Collection<Class<? extends ThingHandlerService>> getServices() {
225         return Collections.singleton(MonitorDiscoveryService.class);
226     }
227
228     public boolean isBackgroundDiscoveryEnabled() {
229         return backgroundDiscoveryEnabled;
230     }
231
232     public Integer getDefaultAlarmDuration() {
233         return defaultAlarmDuration;
234     }
235
236     public @Nullable Integer getDefaultImageRefreshInterval() {
237         return defaultImageRefreshInterval;
238     }
239
240     public List<Monitor> getSavedMonitors() {
241         return savedMonitors;
242     }
243
244     public Gson getGson() {
245         return GSON;
246     }
247
248     public void setFunction(String id, MonitorFunction function) {
249         if (!zmAuth.isAuthorized()) {
250             return;
251         }
252         logger.debug("Bridge: Setting monitor {} function to {}", id, function);
253         executePost(buildUrl(String.format("/api/monitors/%s.json", id)),
254                 String.format("Monitor[Function]=%s", function.toString()));
255     }
256
257     public void setEnabled(String id, OnOffType enabled) {
258         if (!zmAuth.isAuthorized()) {
259             return;
260         }
261         logger.debug("Bridge: Setting monitor {} to {}", id, enabled);
262         executePost(buildUrl(String.format("/api/monitors/%s.json", id)),
263                 String.format("Monitor[Enabled]=%s", enabled == OnOffType.ON ? "1" : "0"));
264     }
265
266     public void setAlarmOn(String id) {
267         if (!zmAuth.isAuthorized()) {
268             return;
269         }
270         logger.debug("Bridge: Turning alarm ON for monitor {}", id);
271         setAlarm(buildUrl(String.format("/api/monitors/alarm/id:%s/command:on.json", id)));
272     }
273
274     public void setAlarmOff(String id) {
275         if (!zmAuth.isAuthorized()) {
276             return;
277         }
278         logger.debug("Bridge: Turning alarm OFF for monitor {}", id);
279         setAlarm(buildUrl(String.format("/api/monitors/alarm/id:%s/command:off.json", id)));
280     }
281
282     public @Nullable RawType getImage(String id, @Nullable Integer imageRefreshIntervalSeconds) {
283         Integer localRefreshInterval = imageRefreshIntervalSeconds;
284         if (localRefreshInterval == null || localRefreshInterval.intValue() < 1 || !zmAuth.isAuthorized()) {
285             return null;
286         }
287         // Call should timeout just before the refresh interval
288         int timeout = Math.min((localRefreshInterval * 1000) - 500, API_TIMEOUT_MSEC);
289         Request request = httpClient.newRequest(buildStreamUrl(id, STREAM_IMAGE));
290         request.method(HttpMethod.GET);
291         request.timeout(timeout, TimeUnit.MILLISECONDS);
292
293         String errorMsg;
294         try {
295             ContentResponse response = request.send();
296             if (response.getStatus() == HttpStatus.OK_200) {
297                 RawType image = new RawType(response.getContent(), response.getHeaders().get(HttpHeader.CONTENT_TYPE));
298                 return image;
299             } else {
300                 errorMsg = String.format("HTTP GET failed: %d, %s", response.getStatus(), response.getReason());
301             }
302         } catch (TimeoutException e) {
303             errorMsg = String.format("TimeoutException: Call to Zoneminder API timed out after {} msec", timeout);
304         } catch (ExecutionException e) {
305             errorMsg = String.format("ExecutionException: %s", e.getMessage());
306         } catch (InterruptedException e) {
307             errorMsg = String.format("InterruptedException: %s", e.getMessage());
308             Thread.currentThread().interrupt();
309         }
310         logger.debug("{}", errorMsg);
311         return null;
312     }
313
314     @SuppressWarnings("null")
315     private synchronized List<Monitor> getMonitors() {
316         List<Monitor> monitorList = new ArrayList<>();
317         if (!zmAuth.isAuthorized()) {
318             return monitorList;
319         }
320         try {
321             String response = executeGet(buildUrl("/api/monitors.json"));
322             MonitorsDTO monitorsDTO = GSON.fromJson(response, MonitorsDTO.class);
323             if (monitorsDTO != null && monitorsDTO.monitorItems != null) {
324                 List<StateOption> options = new ArrayList<>();
325                 for (MonitorItemDTO monitorItemDTO : monitorsDTO.monitorItems) {
326                     MonitorDTO monitorDTO = monitorItemDTO.monitor;
327                     MonitorStatusDTO monitorStatusDTO = monitorItemDTO.monitorStatus;
328                     if (monitorDTO != null && monitorStatusDTO != null) {
329                         Monitor monitor = new Monitor(monitorDTO.id, monitorDTO.name, monitorDTO.function,
330                                 monitorDTO.enabled, monitorStatusDTO.status);
331                         extractEventCounts(monitor, monitorItemDTO);
332                         monitor.setImageUrl(buildStreamUrl(monitorDTO.id, STREAM_IMAGE));
333                         monitor.setVideoUrl(buildStreamUrl(monitorDTO.id, STREAM_VIDEO));
334                         monitorList.add(monitor);
335                         options.add(new StateOption(monitorDTO.id, "Monitor " + monitorDTO.id));
336                     }
337                 }
338                 // Update state options
339                 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_IMAGE_MONITOR_ID),
340                         options);
341                 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_VIDEO_MONITOR_ID),
342                         options);
343                 // Only update alarm and event info for monitors whose handlers are initialized
344                 Set<String> ids = monitorHandlers.keySet();
345                 for (Monitor m : monitorList) {
346                     if (ids.contains(m.getId())) {
347                         m.setState(getState(m.getId()));
348                         m.setLastEvent(getLastEvent(m.getId()));
349                     }
350                 }
351                 updateRunStates();
352             }
353         } catch (JsonSyntaxException e) {
354             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
355         }
356         return monitorList;
357     }
358
359     private void extractEventCounts(Monitor monitor, MonitorItemDTO monitorItemDTO) {
360         /*
361          * The Zoneminder API changed in version 1.36.x such that the event counts moved from the
362          * monitor object to a new event summary object. Therefore, if the event summary object
363          * exists in the JSON response, pull the event counts from that object, otherwise get the
364          * counts from the monitor object.
365          */
366         if (monitorItemDTO.eventSummary != null) {
367             EventSummaryDTO eventSummaryDTO = monitorItemDTO.eventSummary;
368             monitor.setHourEvents(eventSummaryDTO.hourEvents);
369             monitor.setDayEvents(eventSummaryDTO.dayEvents);
370             monitor.setWeekEvents(eventSummaryDTO.weekEvents);
371             monitor.setMonthEvents(eventSummaryDTO.monthEvents);
372             monitor.setTotalEvents(eventSummaryDTO.totalEvents);
373         } else {
374             MonitorDTO monitorDTO = monitorItemDTO.monitor;
375             monitor.setHourEvents(monitorDTO.hourEvents);
376             monitor.setDayEvents(monitorDTO.dayEvents);
377             monitor.setWeekEvents(monitorDTO.weekEvents);
378             monitor.setMonthEvents(monitorDTO.monthEvents);
379             monitor.setTotalEvents(monitorDTO.totalEvents);
380         }
381     }
382
383     @SuppressWarnings("null")
384     private @Nullable Event getLastEvent(String id) {
385         if (!zmAuth.isAuthorized()) {
386             return null;
387         }
388         try {
389             List<String> parameters = new ArrayList<>();
390             parameters.add("sort=StartTime");
391             parameters.add("direction=desc");
392             parameters.add("limit=1");
393             String response = executeGet(buildUrlWithParameters(
394                     String.format("/api/events/index/MonitorId:%s/Name!=:New%%20Event.json", id), parameters));
395             EventsDTO events = GSON.fromJson(response, EventsDTO.class);
396             if (events != null && events.eventsList != null && events.eventsList.size() == 1) {
397                 EventDTO e = events.eventsList.get(0).event;
398                 Event event = new Event(e.eventId, e.name, e.cause, e.notes, e.startTime, e.endTime);
399                 event.setFrames(e.frames);
400                 event.setAlarmFrames(e.alarmFrames);
401                 event.setLength(e.length);
402                 return event;
403             }
404         } catch (JsonSyntaxException e) {
405             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
406         }
407         return null;
408     }
409
410     private void updateRunStates() {
411         if (!zmAuth.isAuthorized() || !isLinked(CHANNEL_RUN_STATE)) {
412             return;
413         }
414         try {
415             String response = executeGet(buildUrl("/api/states.json"));
416             RunStatesDTO runStates = GSON.fromJson(response, RunStatesDTO.class);
417             if (runStates != null) {
418                 List<StateOption> options = new ArrayList<>();
419                 for (RunStateDTO runState : runStates.runStatesList) {
420                     RunState state = runState.runState;
421                     logger.debug("Found runstate: id={}, name={}, desc={}, isActive={}", state.id, state.name,
422                             state.definition, state.isActive);
423                     options.add(new StateOption(state.name, state.name));
424                     if ("1".equals(state.isActive)) {
425                         updateState(CHANNEL_RUN_STATE, new StringType(state.name));
426                     }
427                 }
428                 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_RUN_STATE),
429                         options);
430             }
431         } catch (JsonSyntaxException e) {
432             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
433         }
434     }
435
436     private @Nullable VersionDTO getVersion() {
437         if (!zmAuth.isAuthorized()) {
438             return null;
439         }
440         VersionDTO version = null;
441         try {
442             String response = executeGet(buildUrl("/api/host/getVersion.json"));
443             version = GSON.fromJson(response, VersionDTO.class);
444         } catch (JsonSyntaxException e) {
445             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
446         }
447         return version;
448     }
449
450     private void setAlarm(String url) {
451         executeGet(url);
452     }
453
454     @SuppressWarnings("null")
455     private MonitorState getState(String id) {
456         if (!zmAuth.isAuthorized()) {
457             return MonitorState.UNKNOWN;
458         }
459         try {
460             String response = executeGet(buildUrl(String.format("/api/monitors/alarm/id:%s/command:status.json", id)));
461             MonitorStateDTO monitorState = GSON.fromJson(response, MonitorStateDTO.class);
462             if (monitorState != null) {
463                 MonitorState state = monitorState.state;
464                 return state != null ? state : MonitorState.UNKNOWN;
465             }
466         } catch (JsonSyntaxException e) {
467             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
468         }
469         return MonitorState.UNKNOWN;
470     }
471
472     public @Nullable String executeGet(String url) {
473         try {
474             long startTime = System.currentTimeMillis();
475             String response = HttpUtil.executeUrl("GET", url, API_TIMEOUT_MSEC);
476             logger.trace("Bridge: Http GET of '{}' returned '{}' in {} ms", url, response,
477                     System.currentTimeMillis() - startTime);
478             return response;
479         } catch (IOException e) {
480             logger.debug("Bridge: IOException on GET request, url='{}': {}", url, e.getMessage());
481         }
482         return null;
483     }
484
485     private @Nullable String executePost(String url, String content) {
486         return executePost(url, content, "application/x-www-form-urlencoded");
487     }
488
489     public @Nullable String executePost(String url, String content, String contentType) {
490         try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
491             long startTime = System.currentTimeMillis();
492             String response = HttpUtil.executeUrl("POST", url, inputStream, contentType, API_TIMEOUT_MSEC);
493             logger.trace("Bridge: Http POST content '{}' to '{}' returned: {} in {} ms", content, url, response,
494                     System.currentTimeMillis() - startTime);
495             return response;
496         } catch (IOException e) {
497             logger.debug("Bridge: IOException on POST request, url='{}': {}", url, e.getMessage());
498         }
499         return null;
500     }
501
502     public String buildLoginUrl() {
503         return buildBaseUrl(LOGIN_PATH).toString();
504     }
505
506     public String buildLoginUrl(String tokenParameter) {
507         StringBuilder sb = buildBaseUrl(LOGIN_PATH);
508         sb.append(tokenParameter);
509         return sb.toString();
510     }
511
512     private String buildStreamUrl(String id, String streamType) {
513         List<String> parameters = new ArrayList<>();
514         parameters.add(String.format("mode=%s", streamType));
515         parameters.add(String.format("monitor=%s", id));
516         return buildUrlWithParameters("/cgi-bin/zms", parameters);
517     }
518
519     private String buildUrl(String path) {
520         return buildUrlWithParameters(path, EMPTY_LIST);
521     }
522
523     private String buildUrlWithParameters(String path, List<String> parameters) {
524         StringBuilder sb = buildBaseUrl(path);
525         String joiner = "?";
526         for (String parameter : parameters) {
527             sb.append(joiner).append(parameter);
528             joiner = "&";
529         }
530         if (zmAuth.usingAuthorization()) {
531             sb.append(joiner).append("token=").append(zmAuth.getAccessToken());
532         }
533         return sb.toString();
534     }
535
536     private StringBuilder buildBaseUrl(String path) {
537         StringBuilder sb = new StringBuilder();
538         sb.append(useSSL ? "https://" : "http://");
539         sb.append(host);
540         if (portNumber != null) {
541             sb.append(":").append(portNumber);
542         }
543         sb.append(urlPath);
544         sb.append(path);
545         return sb;
546     }
547
548     private boolean isMonitorIdValid(String id) {
549         return savedMonitors.stream().filter(monitor -> id.equals(monitor.getId())).findAny().isPresent();
550     }
551
552     private boolean isHostValid() {
553         logger.debug("Bridge: Checking for valid Zoneminder host: {}", host);
554         VersionDTO version = getVersion();
555         if (version != null) {
556             if (checkSoftwareVersion(version.version) && checkApiVersion(version.apiVersion)) {
557                 return true;
558             }
559         } else {
560             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Can't get version information");
561         }
562         return false;
563     }
564
565     private boolean checkSoftwareVersion(@Nullable String softwareVersion) {
566         logger.debug("Bridge: Zoneminder software version is {}", softwareVersion);
567         if (softwareVersion != null) {
568             String[] versionParts = softwareVersion.split("\\.");
569             if (versionParts.length >= 2) {
570                 try {
571                     int versionMajor = Integer.parseInt(versionParts[0]);
572                     int versionMinor = Integer.parseInt(versionParts[1]);
573                     if (versionMajor == 1 && versionMinor >= 34) {
574                         logger.debug("Bridge: Zoneminder software version check OK");
575                         return true;
576                     } else {
577                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
578                                 .format("Current Zoneminder version: %s. Requires version >= 1.34.0", softwareVersion));
579                     }
580                 } catch (NumberFormatException e) {
581                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
582                             String.format("Badly formatted version number: %s", softwareVersion));
583                 }
584             } else {
585                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
586                         String.format("Can't parse software version: %s", softwareVersion));
587             }
588         } else {
589             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Software version is null");
590         }
591         return false;
592     }
593
594     private boolean checkApiVersion(@Nullable String apiVersion) {
595         logger.debug("Bridge: Zoneminder API version is {}", apiVersion);
596         if (apiVersion != null) {
597             String[] versionParts = apiVersion.split("\\.");
598             if (versionParts.length >= 2) {
599                 try {
600                     int versionMajor = Integer.parseInt(versionParts[0]);
601                     if (versionMajor >= 2) {
602                         logger.debug("Bridge: Zoneminder API version check OK");
603                         return true;
604                     } else {
605                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
606                                 .format("Requires API version >= 2.0. This Zoneminder is API version {}", apiVersion));
607                     }
608                 } catch (NumberFormatException e) {
609                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
610                             String.format("Badly formatted API version: %s", apiVersion));
611                 }
612             } else {
613                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
614                         String.format("Can't parse API version: %s", apiVersion));
615             }
616         } else {
617             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "API version is null");
618         }
619         return false;
620     }
621
622     @SuppressWarnings("null")
623     private void refreshMonitors() {
624         List<Monitor> monitors = getMonitors();
625         savedMonitors = monitors;
626         for (Monitor monitor : monitors) {
627             ZmMonitorHandler handler = monitorHandlers.get(monitor.getId());
628             if (handler != null) {
629                 handler.updateStatus(monitor);
630             }
631         }
632     }
633
634     private void scheduleRefreshJob() {
635         logger.debug("Bridge: Scheduling monitors refresh job");
636         cancelRefreshJob();
637         refreshMonitorsJob = scheduler.scheduleWithFixedDelay(this::refreshMonitors,
638                 MONITOR_REFRESH_STARTUP_DELAY_SECONDS, monitorRefreshInterval, TimeUnit.SECONDS);
639     }
640
641     private void cancelRefreshJob() {
642         Future<?> localRefreshThermostatsJob = refreshMonitorsJob;
643         if (localRefreshThermostatsJob != null) {
644             localRefreshThermostatsJob.cancel(true);
645             logger.debug("Bridge: Canceling monitors refresh job");
646             refreshMonitorsJob = null;
647         }
648     }
649 }