]> git.basschouten.com Git - openhab-addons.git/blob
a82f7d086afc1d986c21ca6411bb10320604234a
[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 Set.of(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                 return new RawType(response.getContent(), response.getHeaders().get(HttpHeader.CONTENT_TYPE));
298             } else {
299                 errorMsg = String.format("HTTP GET failed: %d, %s", response.getStatus(), response.getReason());
300             }
301         } catch (TimeoutException e) {
302             errorMsg = String.format("TimeoutException: Call to Zoneminder API timed out after {} msec", timeout);
303         } catch (ExecutionException e) {
304             errorMsg = String.format("ExecutionException: %s", e.getMessage());
305         } catch (InterruptedException e) {
306             errorMsg = String.format("InterruptedException: %s", e.getMessage());
307             Thread.currentThread().interrupt();
308         }
309         logger.debug("{}", errorMsg);
310         return null;
311     }
312
313     @SuppressWarnings("null")
314     private synchronized List<Monitor> getMonitors() {
315         List<Monitor> monitorList = new ArrayList<>();
316         if (!zmAuth.isAuthorized()) {
317             return monitorList;
318         }
319         try {
320             String response = executeGet(buildUrl("/api/monitors.json"));
321             MonitorsDTO monitorsDTO = GSON.fromJson(response, MonitorsDTO.class);
322             if (monitorsDTO != null && monitorsDTO.monitorItems != null) {
323                 List<StateOption> options = new ArrayList<>();
324                 for (MonitorItemDTO monitorItemDTO : monitorsDTO.monitorItems) {
325                     MonitorDTO monitorDTO = monitorItemDTO.monitor;
326                     MonitorStatusDTO monitorStatusDTO = monitorItemDTO.monitorStatus;
327                     if (monitorDTO != null && monitorStatusDTO != null) {
328                         Monitor monitor = new Monitor(monitorDTO.id, monitorDTO.name, monitorDTO.function,
329                                 monitorDTO.enabled, monitorStatusDTO.status);
330                         extractEventCounts(monitor, monitorItemDTO);
331                         monitor.setImageUrl(buildStreamUrl(monitorDTO.id, STREAM_IMAGE));
332                         monitor.setVideoUrl(buildStreamUrl(monitorDTO.id, STREAM_VIDEO));
333                         monitorList.add(monitor);
334                         options.add(new StateOption(monitorDTO.id, "Monitor " + monitorDTO.id));
335                     }
336                 }
337                 // Update state options
338                 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_IMAGE_MONITOR_ID),
339                         options);
340                 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_VIDEO_MONITOR_ID),
341                         options);
342                 // Only update alarm and event info for monitors whose handlers are initialized
343                 Set<String> ids = monitorHandlers.keySet();
344                 for (Monitor m : monitorList) {
345                     if (ids.contains(m.getId())) {
346                         m.setState(getState(m.getId()));
347                         m.setLastEvent(getLastEvent(m.getId()));
348                     }
349                 }
350                 updateRunStates();
351             }
352         } catch (JsonSyntaxException e) {
353             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
354         }
355         return monitorList;
356     }
357
358     private void extractEventCounts(Monitor monitor, MonitorItemDTO monitorItemDTO) {
359         /*
360          * The Zoneminder API changed in version 1.36.x such that the event counts moved from the
361          * monitor object to a new event summary object. Therefore, if the event summary object
362          * exists in the JSON response, pull the event counts from that object, otherwise get the
363          * counts from the monitor object.
364          */
365         if (monitorItemDTO.eventSummary != null) {
366             EventSummaryDTO eventSummaryDTO = monitorItemDTO.eventSummary;
367             monitor.setHourEvents(eventSummaryDTO.hourEvents);
368             monitor.setDayEvents(eventSummaryDTO.dayEvents);
369             monitor.setWeekEvents(eventSummaryDTO.weekEvents);
370             monitor.setMonthEvents(eventSummaryDTO.monthEvents);
371             monitor.setTotalEvents(eventSummaryDTO.totalEvents);
372         } else {
373             MonitorDTO monitorDTO = monitorItemDTO.monitor;
374             monitor.setHourEvents(monitorDTO.hourEvents);
375             monitor.setDayEvents(monitorDTO.dayEvents);
376             monitor.setWeekEvents(monitorDTO.weekEvents);
377             monitor.setMonthEvents(monitorDTO.monthEvents);
378             monitor.setTotalEvents(monitorDTO.totalEvents);
379         }
380     }
381
382     @SuppressWarnings("null")
383     private @Nullable Event getLastEvent(String id) {
384         if (!zmAuth.isAuthorized()) {
385             return null;
386         }
387         try {
388             List<String> parameters = new ArrayList<>();
389             parameters.add("sort=StartTime");
390             parameters.add("direction=desc");
391             parameters.add("limit=1");
392             String response = executeGet(buildUrlWithParameters(
393                     String.format("/api/events/index/MonitorId:%s/Name!=:New%%20Event.json", id), parameters));
394             EventsDTO events = GSON.fromJson(response, EventsDTO.class);
395             if (events != null && events.eventsList != null && events.eventsList.size() == 1) {
396                 EventDTO e = events.eventsList.get(0).event;
397                 Event event = new Event(e.eventId, e.name, e.cause, e.notes, e.startTime, e.endTime);
398                 event.setFrames(e.frames);
399                 event.setAlarmFrames(e.alarmFrames);
400                 event.setLength(e.length);
401                 return event;
402             }
403         } catch (JsonSyntaxException e) {
404             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
405         }
406         return null;
407     }
408
409     private void updateRunStates() {
410         if (!zmAuth.isAuthorized() || !isLinked(CHANNEL_RUN_STATE)) {
411             return;
412         }
413         try {
414             String response = executeGet(buildUrl("/api/states.json"));
415             RunStatesDTO runStates = GSON.fromJson(response, RunStatesDTO.class);
416             if (runStates != null) {
417                 List<StateOption> options = new ArrayList<>();
418                 for (RunStateDTO runState : runStates.runStatesList) {
419                     RunState state = runState.runState;
420                     logger.debug("Found runstate: id={}, name={}, desc={}, isActive={}", state.id, state.name,
421                             state.definition, state.isActive);
422                     options.add(new StateOption(state.name, state.name));
423                     if ("1".equals(state.isActive)) {
424                         updateState(CHANNEL_RUN_STATE, new StringType(state.name));
425                     }
426                 }
427                 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_RUN_STATE),
428                         options);
429             }
430         } catch (JsonSyntaxException e) {
431             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
432         }
433     }
434
435     private @Nullable VersionDTO getVersion() {
436         if (!zmAuth.isAuthorized()) {
437             return null;
438         }
439         VersionDTO version = null;
440         try {
441             String response = executeGet(buildUrl("/api/host/getVersion.json"));
442             version = GSON.fromJson(response, VersionDTO.class);
443         } catch (JsonSyntaxException e) {
444             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
445         }
446         return version;
447     }
448
449     private void setAlarm(String url) {
450         executeGet(url);
451     }
452
453     @SuppressWarnings("null")
454     private MonitorState getState(String id) {
455         if (!zmAuth.isAuthorized()) {
456             return MonitorState.UNKNOWN;
457         }
458         try {
459             String response = executeGet(buildUrl(String.format("/api/monitors/alarm/id:%s/command:status.json", id)));
460             MonitorStateDTO monitorState = GSON.fromJson(response, MonitorStateDTO.class);
461             if (monitorState != null) {
462                 MonitorState state = monitorState.state;
463                 return state != null ? state : MonitorState.UNKNOWN;
464             }
465         } catch (JsonSyntaxException e) {
466             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
467         }
468         return MonitorState.UNKNOWN;
469     }
470
471     public @Nullable String executeGet(String url) {
472         try {
473             long startTime = System.currentTimeMillis();
474             String response = HttpUtil.executeUrl("GET", url, API_TIMEOUT_MSEC);
475             logger.trace("Bridge: Http GET of '{}' returned '{}' in {} ms", url, response,
476                     System.currentTimeMillis() - startTime);
477             return response;
478         } catch (IOException e) {
479             logger.debug("Bridge: IOException on GET request, url='{}': {}", url, e.getMessage());
480         }
481         return null;
482     }
483
484     private @Nullable String executePost(String url, String content) {
485         return executePost(url, content, "application/x-www-form-urlencoded");
486     }
487
488     public @Nullable String executePost(String url, String content, String contentType) {
489         try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
490             long startTime = System.currentTimeMillis();
491             String response = HttpUtil.executeUrl("POST", url, inputStream, contentType, API_TIMEOUT_MSEC);
492             logger.trace("Bridge: Http POST content '{}' to '{}' returned: {} in {} ms", content, url, response,
493                     System.currentTimeMillis() - startTime);
494             return response;
495         } catch (IOException e) {
496             logger.debug("Bridge: IOException on POST request, url='{}': {}", url, e.getMessage());
497         }
498         return null;
499     }
500
501     public String buildLoginUrl() {
502         return buildBaseUrl(LOGIN_PATH).toString();
503     }
504
505     public String buildLoginUrl(String tokenParameter) {
506         StringBuilder sb = buildBaseUrl(LOGIN_PATH);
507         sb.append(tokenParameter);
508         return sb.toString();
509     }
510
511     private String buildStreamUrl(String id, String streamType) {
512         List<String> parameters = new ArrayList<>();
513         parameters.add(String.format("mode=%s", streamType));
514         parameters.add(String.format("monitor=%s", id));
515         return buildUrlWithParameters("/cgi-bin/zms", parameters);
516     }
517
518     private String buildUrl(String path) {
519         return buildUrlWithParameters(path, EMPTY_LIST);
520     }
521
522     private String buildUrlWithParameters(String path, List<String> parameters) {
523         StringBuilder sb = buildBaseUrl(path);
524         String joiner = "?";
525         for (String parameter : parameters) {
526             sb.append(joiner).append(parameter);
527             joiner = "&";
528         }
529         if (zmAuth.usingAuthorization()) {
530             sb.append(joiner).append("token=").append(zmAuth.getAccessToken());
531         }
532         return sb.toString();
533     }
534
535     private StringBuilder buildBaseUrl(String path) {
536         StringBuilder sb = new StringBuilder();
537         sb.append(useSSL ? "https://" : "http://");
538         sb.append(host);
539         if (portNumber != null) {
540             sb.append(":").append(portNumber);
541         }
542         sb.append(urlPath);
543         sb.append(path);
544         return sb;
545     }
546
547     private boolean isMonitorIdValid(String id) {
548         return savedMonitors.stream().filter(monitor -> id.equals(monitor.getId())).findAny().isPresent();
549     }
550
551     private boolean isHostValid() {
552         logger.debug("Bridge: Checking for valid Zoneminder host: {}", host);
553         VersionDTO version = getVersion();
554         if (version != null) {
555             if (checkSoftwareVersion(version.version) && checkApiVersion(version.apiVersion)) {
556                 return true;
557             }
558         } else {
559             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Can't get version information");
560         }
561         return false;
562     }
563
564     private boolean checkSoftwareVersion(@Nullable String softwareVersion) {
565         logger.debug("Bridge: Zoneminder software version is {}", softwareVersion);
566         if (softwareVersion != null) {
567             String[] versionParts = softwareVersion.split("\\.");
568             if (versionParts.length >= 2) {
569                 try {
570                     int versionMajor = Integer.parseInt(versionParts[0]);
571                     int versionMinor = Integer.parseInt(versionParts[1]);
572                     if (versionMajor == 1 && versionMinor >= 34) {
573                         logger.debug("Bridge: Zoneminder software version check OK");
574                         return true;
575                     } else {
576                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
577                                 .format("Current Zoneminder version: %s. Requires version >= 1.34.0", softwareVersion));
578                     }
579                 } catch (NumberFormatException e) {
580                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
581                             String.format("Badly formatted version number: %s", softwareVersion));
582                 }
583             } else {
584                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
585                         String.format("Can't parse software version: %s", softwareVersion));
586             }
587         } else {
588             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Software version is null");
589         }
590         return false;
591     }
592
593     private boolean checkApiVersion(@Nullable String apiVersion) {
594         logger.debug("Bridge: Zoneminder API version is {}", apiVersion);
595         if (apiVersion != null) {
596             String[] versionParts = apiVersion.split("\\.");
597             if (versionParts.length >= 2) {
598                 try {
599                     int versionMajor = Integer.parseInt(versionParts[0]);
600                     if (versionMajor >= 2) {
601                         logger.debug("Bridge: Zoneminder API version check OK");
602                         return true;
603                     } else {
604                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
605                                 .format("Requires API version >= 2.0. This Zoneminder is API version {}", apiVersion));
606                     }
607                 } catch (NumberFormatException e) {
608                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
609                             String.format("Badly formatted API version: %s", apiVersion));
610                 }
611             } else {
612                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
613                         String.format("Can't parse API version: %s", apiVersion));
614             }
615         } else {
616             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "API version is null");
617         }
618         return false;
619     }
620
621     @SuppressWarnings("null")
622     private void refreshMonitors() {
623         List<Monitor> monitors = getMonitors();
624         savedMonitors = monitors;
625         for (Monitor monitor : monitors) {
626             ZmMonitorHandler handler = monitorHandlers.get(monitor.getId());
627             if (handler != null) {
628                 handler.updateStatus(monitor);
629             }
630         }
631     }
632
633     private void scheduleRefreshJob() {
634         logger.debug("Bridge: Scheduling monitors refresh job");
635         cancelRefreshJob();
636         refreshMonitorsJob = scheduler.scheduleWithFixedDelay(this::refreshMonitors,
637                 MONITOR_REFRESH_STARTUP_DELAY_SECONDS, monitorRefreshInterval, TimeUnit.SECONDS);
638     }
639
640     private void cancelRefreshJob() {
641         Future<?> localRefreshThermostatsJob = refreshMonitorsJob;
642         if (localRefreshThermostatsJob != null) {
643             localRefreshThermostatsJob.cancel(true);
644             logger.debug("Bridge: Canceling monitors refresh job");
645             refreshMonitorsJob = null;
646         }
647     }
648 }