]> git.basschouten.com Git - openhab-addons.git/blob
61b645db289e1c17dca68fe1a3260294f484ac66
[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.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 ? "ENABLED" : "DISABLED");
145
146         host = config.host;
147         useSSL = config.useSSL.booleanValue();
148         portNumber = config.portNumber != null ? Integer.toString(config.portNumber) : null;
149         urlPath = "/".equals(config.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                 }
322                 // Update state options
323                 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_IMAGE_MONITOR_ID),
324                         options);
325                 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_VIDEO_MONITOR_ID),
326                         options);
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()));
333                     }
334                 }
335             }
336         } catch (JsonSyntaxException e) {
337             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
338         }
339         return monitorList;
340     }
341
342     private void extractEventCounts(Monitor monitor, MonitorItemDTO monitorItemDTO) {
343         /*
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.
348          */
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);
356         } else {
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);
363         }
364     }
365
366     @SuppressWarnings("null")
367     private @Nullable Event getLastEvent(String id) {
368         if (!zmAuth.isAuthorized()) {
369             return null;
370         }
371         try {
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);
385                 return event;
386             }
387         } catch (JsonSyntaxException e) {
388             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
389         }
390         return null;
391     }
392
393     private @Nullable VersionDTO getVersion() {
394         if (!zmAuth.isAuthorized()) {
395             return null;
396         }
397         VersionDTO version = null;
398         try {
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);
403         }
404         return version;
405     }
406
407     private void setAlarm(String url) {
408         executeGet(url);
409     }
410
411     @SuppressWarnings("null")
412     private MonitorState getState(String id) {
413         if (!zmAuth.isAuthorized()) {
414             return MonitorState.UNKNOWN;
415         }
416         try {
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;
422             }
423         } catch (JsonSyntaxException e) {
424             logger.debug("Bridge: JsonSyntaxException: {}", e.getMessage(), e);
425         }
426         return MonitorState.UNKNOWN;
427     }
428
429     public @Nullable String executeGet(String url) {
430         try {
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);
435             return response;
436         } catch (IOException e) {
437             logger.debug("Bridge: IOException on GET request, url='{}': {}", url, e.getMessage());
438         }
439         return null;
440     }
441
442     private @Nullable String executePost(String url, String content) {
443         return executePost(url, content, "application/x-www-form-urlencoded");
444     }
445
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);
452             return response;
453         } catch (IOException e) {
454             logger.debug("Bridge: IOException on POST request, url='{}': {}", url, e.getMessage());
455         }
456         return null;
457     }
458
459     public String buildLoginUrl() {
460         return buildBaseUrl(LOGIN_PATH).toString();
461     }
462
463     public String buildLoginUrl(String tokenParameter) {
464         StringBuilder sb = buildBaseUrl(LOGIN_PATH);
465         sb.append(tokenParameter);
466         return sb.toString();
467     }
468
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);
474     }
475
476     private String buildUrl(String path) {
477         return buildUrlWithParameters(path, EMPTY_LIST);
478     }
479
480     private String buildUrlWithParameters(String path, List<String> parameters) {
481         StringBuilder sb = buildBaseUrl(path);
482         String joiner = "?";
483         for (String parameter : parameters) {
484             sb.append(joiner).append(parameter);
485             joiner = "&";
486         }
487         if (zmAuth.usingAuthorization()) {
488             sb.append(joiner).append("token=").append(zmAuth.getAccessToken());
489         }
490         return sb.toString();
491     }
492
493     private StringBuilder buildBaseUrl(String path) {
494         StringBuilder sb = new StringBuilder();
495         sb.append(useSSL ? "https://" : "http://");
496         sb.append(host);
497         if (portNumber != null) {
498             sb.append(":").append(portNumber);
499         }
500         sb.append(urlPath);
501         sb.append(path);
502         return sb;
503     }
504
505     private boolean isMonitorIdValid(String id) {
506         return savedMonitors.stream().filter(monitor -> id.equals(monitor.getId())).findAny().isPresent();
507     }
508
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)) {
514                 return true;
515             }
516         } else {
517             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Can't get version information");
518         }
519         return false;
520     }
521
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) {
527                 try {
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");
532                         return true;
533                     } else {
534                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
535                                 .format("Current Zoneminder version: %s. Requires version >= 1.34.0", softwareVersion));
536                     }
537                 } catch (NumberFormatException e) {
538                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
539                             String.format("Badly formatted version number: %s", softwareVersion));
540                 }
541             } else {
542                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
543                         String.format("Can't parse software version: %s", softwareVersion));
544             }
545         } else {
546             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Software version is null");
547         }
548         return false;
549     }
550
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) {
556                 try {
557                     int versionMajor = Integer.parseInt(versionParts[0]);
558                     if (versionMajor >= 2) {
559                         logger.debug("Bridge: Zoneminder API version check OK");
560                         return true;
561                     } else {
562                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String
563                                 .format("Requires API version >= 2.0. This Zoneminder is API version {}", apiVersion));
564                     }
565                 } catch (NumberFormatException e) {
566                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
567                             String.format("Badly formatted API version: %s", apiVersion));
568                 }
569             } else {
570                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
571                         String.format("Can't parse API version: %s", apiVersion));
572             }
573         } else {
574             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "API version is null");
575         }
576         return false;
577     }
578
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);
587             }
588         }
589     }
590
591     private void scheduleRefreshJob() {
592         logger.debug("Bridge: Scheduling monitors refresh job");
593         cancelRefreshJob();
594         refreshMonitorsJob = scheduler.scheduleWithFixedDelay(this::refreshMonitors,
595                 MONITOR_REFRESH_STARTUP_DELAY_SECONDS, monitorRefreshInterval, TimeUnit.SECONDS);
596     }
597
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;
604         }
605     }
606 }