]> git.basschouten.com Git - openhab-addons.git/blob
4fd9a2362e9e52baf7047bdb469939bb67f0535f
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.nio.charset.StandardCharsets;
20 import java.util.ArrayList;
21 import java.util.Collection;
22 import java.util.Collections;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Set;
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
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;
70
71 import com.google.gson.Gson;
72 import com.google.gson.GsonBuilder;
73 import com.google.gson.JsonSyntaxException;
74
75 /**
76  * The {@link ZmBridgeHandler} represents the Zoneminder server. It handles all communication
77  * with the Zoneminder server.
78  *
79  * @author Mark Hilbush - Initial contribution
80  */
81 @NonNullByDefault
82 public class ZmBridgeHandler extends BaseBridgeHandler {
83
84     private static final int MONITOR_REFRESH_INTERVAL_SECONDS = 10;
85     private static final int MONITOR_REFRESH_STARTUP_DELAY_SECONDS = 5;
86
87     private static final int API_TIMEOUT_MSEC = 10000;
88
89     private static final String LOGIN_PATH = "/api/host/login.json";
90
91     private static final String STREAM_IMAGE = "single";
92     private static final String STREAM_VIDEO = "jpeg";
93
94     private static final List<String> EMPTY_LIST = Collections.emptyList();
95
96     private static final Gson GSON = new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create();
97
98     private final Logger logger = LoggerFactory.getLogger(ZmBridgeHandler.class);
99
100     private @Nullable Future<?> refreshMonitorsJob;
101
102     private List<Monitor> savedMonitors = new ArrayList<>();
103
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;
112
113     private final HttpClient httpClient;
114     private final ZmStateDescriptionOptionsProvider stateDescriptionProvider;
115
116     private ZmAuth zmAuth;
117
118     // Maintain mapping of handler and monitor id
119     private final Map<String, ZmMonitorHandler> monitorHandlers = new ConcurrentHashMap<>();
120
121     public ZmBridgeHandler(Bridge thing, HttpClient httpClient,
122             ZmStateDescriptionOptionsProvider stateDescriptionProvider) {
123         super(thing);
124         this.httpClient = httpClient;
125         this.stateDescriptionProvider = stateDescriptionProvider;
126         // Default to use no authentication
127         zmAuth = new ZmAuth(this);
128     }
129
130     @Override
131     public void initialize() {
132         ZmBridgeConfig config = getConfigAs(ZmBridgeConfig.class);
133
134         Integer value;
135         value = config.refreshInterval;
136         monitorRefreshInterval = value == null ? MONITOR_REFRESH_INTERVAL_SECONDS : value;
137
138         value = config.defaultAlarmDuration;
139         defaultAlarmDuration = value == null ? DEFAULT_ALARM_DURATION_SECONDS : value;
140
141         defaultImageRefreshInterval = config.defaultImageRefreshInterval;
142
143         backgroundDiscoveryEnabled = config.discoveryEnabled;
144         logger.debug("Bridge: Background discovery is {}", backgroundDiscoveryEnabled == true ? "ENABLED" : "DISABLED");
145
146         host = config.host;
147         useSSL = config.useSSL.booleanValue();
148         portNumber = config.portNumber != null ? Integer.toString(config.portNumber) : null;
149         urlPath = config.urlPath;
150
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);
154         }
155         if (isHostValid()) {
156             updateStatus(ThingStatus.ONLINE);
157             scheduleRefreshJob();
158         }
159     }
160
161     @Override
162     public void dispose() {
163         cancelRefreshJob();
164     }
165
166     @Override
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);
171     }
172
173     @Override
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);
178     }
179
180     @Override
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);
185                 break;
186             case CHANNEL_VIDEO_MONITOR_ID:
187                 handleMonitorIdCommand(command, CHANNEL_VIDEO_MONITOR_ID, CHANNEL_VIDEO_URL, STREAM_VIDEO);
188                 break;
189         }
190     }
191
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)));
200             } else {
201                 updateState(monitorIdChannelId, UnDefType.UNDEF);
202                 updateState(urlChannelId, UnDefType.UNDEF);
203             }
204         }
205     }
206
207     @Override
208     public Collection<Class<? extends ThingHandlerService>> getServices() {
209         return Collections.singleton(MonitorDiscoveryService.class);
210     }
211
212     public boolean isBackgroundDiscoveryEnabled() {
213         return backgroundDiscoveryEnabled;
214     }
215
216     public Integer getDefaultAlarmDuration() {
217         return defaultAlarmDuration;
218     }
219
220     public @Nullable Integer getDefaultImageRefreshInterval() {
221         return defaultImageRefreshInterval;
222     }
223
224     public List<Monitor> getSavedMonitors() {
225         return savedMonitors;
226     }
227
228     public Gson getGson() {
229         return GSON;
230     }
231
232     public void setFunction(String id, MonitorFunction function) {
233         if (!zmAuth.isAuthorized()) {
234             return;
235         }
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()));
239     }
240
241     public void setEnabled(String id, OnOffType enabled) {
242         if (!zmAuth.isAuthorized()) {
243             return;
244         }
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"));
248     }
249
250     public void setAlarmOn(String id) {
251         if (!zmAuth.isAuthorized()) {
252             return;
253         }
254         logger.debug("Bridge: Turning alarm ON for monitor {}", id);
255         setAlarm(buildUrl(String.format("/api/monitors/alarm/id:%s/command:on.json", id)));
256     }
257
258     public void setAlarmOff(String id) {
259         if (!zmAuth.isAuthorized()) {
260             return;
261         }
262         logger.debug("Bridge: Turning alarm OFF for monitor {}", id);
263         setAlarm(buildUrl(String.format("/api/monitors/alarm/id:%s/command:off.json", id)));
264     }
265
266     public @Nullable RawType getImage(String id, @Nullable Integer imageRefreshIntervalSeconds) {
267         Integer localRefreshInterval = imageRefreshIntervalSeconds;
268         if (localRefreshInterval == null || localRefreshInterval.intValue() < 1 || !zmAuth.isAuthorized()) {
269             return null;
270         }
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);
276
277         String errorMsg;
278         try {
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));
282                 return image;
283             } else {
284                 errorMsg = String.format("HTTP GET failed: %d, %s", response.getStatus(), response.getReason());
285             }
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();
293         }
294         logger.debug("{}", errorMsg);
295         return null;
296     }
297
298     @SuppressWarnings("null")
299     private synchronized List<Monitor> getMonitors() {
300         List<Monitor> monitorList = new ArrayList<>();
301         if (!zmAuth.isAuthorized()) {
302             return monitorList;
303         }
304         try {
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));
320                     }
321                     stateDescriptionProvider
322                             .setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_IMAGE_MONITOR_ID), options);
323                     stateDescriptionProvider
324                             .setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_VIDEO_MONITOR_ID), options);
325                 }
326                 // Only update alarm and event info for monitors whose handlers are initialized
327                 Set<String> ids = monitorHandlers.keySet();
328                 for (Monitor m : monitorList) {
329                     if (ids.contains(m.getId())) {
330                         m.setState(getState(m.getId()));
331                         m.setLastEvent(getLastEvent(m.getId()));
332                     }
333                 }
334             }
335         } catch (JsonSyntaxException e) {
336             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
337         }
338         return monitorList;
339     }
340
341     private void extractEventCounts(Monitor monitor, MonitorItemDTO monitorItemDTO) {
342         /*
343          * The Zoneminder API changed in version 1.36.x such that the event counts moved from the
344          * monitor object to a new event summary object. Therefore, if the event summary object
345          * exists in the JSON response, pull the event counts from that object, otherwise get the
346          * counts from the monitor object.
347          */
348         if (monitorItemDTO.eventSummary != null) {
349             EventSummaryDTO eventSummaryDTO = monitorItemDTO.eventSummary;
350             monitor.setHourEvents(eventSummaryDTO.hourEvents);
351             monitor.setDayEvents(eventSummaryDTO.dayEvents);
352             monitor.setWeekEvents(eventSummaryDTO.weekEvents);
353             monitor.setMonthEvents(eventSummaryDTO.monthEvents);
354             monitor.setTotalEvents(eventSummaryDTO.totalEvents);
355         } else {
356             MonitorDTO monitorDTO = monitorItemDTO.monitor;
357             monitor.setHourEvents(monitorDTO.hourEvents);
358             monitor.setDayEvents(monitorDTO.dayEvents);
359             monitor.setWeekEvents(monitorDTO.weekEvents);
360             monitor.setMonthEvents(monitorDTO.monthEvents);
361             monitor.setTotalEvents(monitorDTO.totalEvents);
362         }
363     }
364
365     @SuppressWarnings("null")
366     private @Nullable Event getLastEvent(String id) {
367         if (!zmAuth.isAuthorized()) {
368             return null;
369         }
370         try {
371             List<String> parameters = new ArrayList<>();
372             parameters.add("sort=StartTime");
373             parameters.add("direction=desc");
374             parameters.add("limit=1");
375             String response = executeGet(buildUrlWithParameters(
376                     String.format("/api/events/index/MonitorId:%s/Name!=:New%%20Event.json", id), parameters));
377             EventsDTO events = GSON.fromJson(response, EventsDTO.class);
378             if (events != null && events.eventsList != null && events.eventsList.size() == 1) {
379                 EventDTO e = events.eventsList.get(0).event;
380                 Event event = new Event(e.eventId, e.name, e.cause, e.notes, e.startTime, e.endTime);
381                 event.setFrames(e.frames);
382                 event.setAlarmFrames(e.alarmFrames);
383                 event.setLength(e.length);
384                 return event;
385             }
386         } catch (JsonSyntaxException e) {
387             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
388         }
389         return null;
390     }
391
392     private @Nullable VersionDTO getVersion() {
393         if (!zmAuth.isAuthorized()) {
394             return null;
395         }
396         VersionDTO version = null;
397         try {
398             String response = executeGet(buildUrl("/api/host/getVersion.json"));
399             version = GSON.fromJson(response, VersionDTO.class);
400         } catch (JsonSyntaxException e) {
401             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
402         }
403         return version;
404     }
405
406     private void setAlarm(String url) {
407         executeGet(url);
408     }
409
410     @SuppressWarnings("null")
411     private MonitorState getState(String id) {
412         if (!zmAuth.isAuthorized()) {
413             return MonitorState.UNKNOWN;
414         }
415         try {
416             String response = executeGet(buildUrl(String.format("/api/monitors/alarm/id:%s/command:status.json", id)));
417             MonitorStateDTO monitorState = GSON.fromJson(response, MonitorStateDTO.class);
418             if (monitorState != null) {
419                 MonitorState state = monitorState.state;
420                 return state != null ? state : MonitorState.UNKNOWN;
421             }
422         } catch (JsonSyntaxException e) {
423             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
424         }
425         return MonitorState.UNKNOWN;
426     }
427
428     public @Nullable String executeGet(String url) {
429         try {
430             long startTime = System.currentTimeMillis();
431             String response = HttpUtil.executeUrl("GET", url, API_TIMEOUT_MSEC);
432             logger.trace("Bridge: Http GET of '{}' returned '{}' in {} ms", url, response,
433                     System.currentTimeMillis() - startTime);
434             return response;
435         } catch (IOException e) {
436             logger.debug("Bridge: IOException on GET request, url='{}': {}", url, e.getMessage());
437         }
438         return null;
439     }
440
441     private @Nullable String executePost(String url, String content) {
442         return executePost(url, content, "application/x-www-form-urlencoded");
443     }
444
445     public @Nullable String executePost(String url, String content, String contentType) {
446         try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
447             long startTime = System.currentTimeMillis();
448             String response = HttpUtil.executeUrl("POST", url, inputStream, contentType, API_TIMEOUT_MSEC);
449             logger.trace("Bridge: Http POST content '{}' to '{}' returned: {} in {} ms", content, url, response,
450                     System.currentTimeMillis() - startTime);
451             return response;
452         } catch (IOException e) {
453             logger.debug("Bridge: IOException on POST request, url='{}': {}", url, e.getMessage());
454         }
455         return null;
456     }
457
458     public String buildLoginUrl() {
459         return buildBaseUrl(LOGIN_PATH).toString();
460     }
461
462     public String buildLoginUrl(String tokenParameter) {
463         StringBuilder sb = buildBaseUrl(LOGIN_PATH);
464         sb.append(tokenParameter);
465         return sb.toString();
466     }
467
468     private String buildStreamUrl(String id, String streamType) {
469         List<String> parameters = new ArrayList<>();
470         parameters.add(String.format("mode=%s", streamType));
471         parameters.add(String.format("monitor=%s", id));
472         return buildUrlWithParameters("/cgi-bin/zms", parameters);
473     }
474
475     private String buildUrl(String path) {
476         return buildUrlWithParameters(path, EMPTY_LIST);
477     }
478
479     private String buildUrlWithParameters(String path, List<String> parameters) {
480         StringBuilder sb = buildBaseUrl(path);
481         String joiner = "?";
482         for (String parameter : parameters) {
483             sb.append(joiner).append(parameter);
484             joiner = "&";
485         }
486         if (zmAuth.usingAuthorization()) {
487             sb.append(joiner).append("token=").append(zmAuth.getAccessToken());
488         }
489         return sb.toString();
490     }
491
492     private StringBuilder buildBaseUrl(String path) {
493         StringBuilder sb = new StringBuilder();
494         sb.append(useSSL ? "https://" : "http://");
495         sb.append(host);
496         if (portNumber != null) {
497             sb.append(":").append(portNumber);
498         }
499         sb.append(urlPath);
500         sb.append(path);
501         return sb;
502     }
503
504     private boolean isMonitorIdValid(String id) {
505         return savedMonitors.stream().filter(monitor -> id.equals(monitor.getId())).findAny().isPresent();
506     }
507
508     private boolean isHostValid() {
509         logger.debug("Bridge: Checking for valid Zoneminder host: {}", host);
510         VersionDTO version = getVersion();
511         if (version != null) {
512             if (checkSoftwareVersion(version.version) && checkApiVersion(version.apiVersion)) {
513                 return true;
514             }
515         } else {
516             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Can't get version information");
517         }
518         return false;
519     }
520
521     private boolean checkSoftwareVersion(@Nullable String softwareVersion) {
522         logger.debug("Bridge: Zoneminder software version is {}", softwareVersion);
523         if (softwareVersion != null) {
524             String[] versionParts = softwareVersion.split("\\.");
525             if (versionParts.length >= 2) {
526                 try {
527                     int versionMajor = Integer.parseInt(versionParts[0]);
528                     int versionMinor = Integer.parseInt(versionParts[1]);
529                     if (versionMajor == 1 && versionMinor >= 34) {
530                         logger.debug("Bridge: Zoneminder software version check OK");
531                         return true;
532                     } else {
533                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
534                                 .format("Current Zoneminder version: %s. Requires version >= 1.34.0", softwareVersion));
535                     }
536                 } catch (NumberFormatException e) {
537                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
538                             String.format("Badly formatted version number: %s", softwareVersion));
539                 }
540             } else {
541                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
542                         String.format("Can't parse software version: %s", softwareVersion));
543             }
544         } else {
545             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Software version is null");
546         }
547         return false;
548     }
549
550     private boolean checkApiVersion(@Nullable String apiVersion) {
551         logger.debug("Bridge: Zoneminder API version is {}", apiVersion);
552         if (apiVersion != null) {
553             String[] versionParts = apiVersion.split("\\.");
554             if (versionParts.length >= 2) {
555                 try {
556                     int versionMajor = Integer.parseInt(versionParts[0]);
557                     if (versionMajor >= 2) {
558                         logger.debug("Bridge: Zoneminder API version check OK");
559                         return true;
560                     } else {
561                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
562                                 .format("Requires API version >= 2.0. This Zoneminder is API version {}", apiVersion));
563                     }
564                 } catch (NumberFormatException e) {
565                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
566                             String.format("Badly formatted API version: %s", apiVersion));
567                 }
568             } else {
569                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
570                         String.format("Can't parse API version: %s", apiVersion));
571             }
572         } else {
573             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "API version is null");
574         }
575         return false;
576     }
577
578     @SuppressWarnings("null")
579     private void refreshMonitors() {
580         List<Monitor> monitors = getMonitors();
581         savedMonitors = monitors;
582         for (Monitor monitor : monitors) {
583             ZmMonitorHandler handler = monitorHandlers.get(monitor.getId());
584             if (handler != null) {
585                 handler.updateStatus(monitor);
586             }
587         }
588     }
589
590     private void scheduleRefreshJob() {
591         logger.debug("Bridge: Scheduling monitors refresh job");
592         cancelRefreshJob();
593         refreshMonitorsJob = scheduler.scheduleWithFixedDelay(this::refreshMonitors,
594                 MONITOR_REFRESH_STARTUP_DELAY_SECONDS, monitorRefreshInterval, TimeUnit.SECONDS);
595     }
596
597     private void cancelRefreshJob() {
598         Future<?> localRefreshThermostatsJob = refreshMonitorsJob;
599         if (localRefreshThermostatsJob != null) {
600             localRefreshThermostatsJob.cancel(true);
601             logger.debug("Bridge: Canceling monitors refresh job");
602             refreshMonitorsJob = null;
603         }
604     }
605 }