]> git.basschouten.com Git - openhab-addons.git/blob
d87d6fd6c633761f4a21890752d0d738bdcee6ec
[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
14 package org.openhab.binding.speedtest.internal;
15
16 import java.io.BufferedReader;
17 import java.io.File;
18 import java.io.IOException;
19 import java.io.InputStreamReader;
20 import java.time.ZonedDateTime;
21 import java.time.format.DateTimeParseException;
22 import java.util.Arrays;
23 import java.util.Map;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26 import java.util.regex.PatternSyntaxException;
27
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.openhab.binding.speedtest.internal.dto.ResultContainer;
31 import org.openhab.binding.speedtest.internal.dto.ResultsContainerServerList;
32 import org.openhab.core.i18n.TimeZoneProvider;
33 import org.openhab.core.io.net.http.HttpUtil;
34 import org.openhab.core.library.types.DateTimeType;
35 import org.openhab.core.library.types.OnOffType;
36 import org.openhab.core.library.types.QuantityType;
37 import org.openhab.core.library.types.RawType;
38 import org.openhab.core.library.types.StringType;
39 import org.openhab.core.library.unit.Units;
40 import org.openhab.core.thing.ChannelUID;
41 import org.openhab.core.thing.Thing;
42 import org.openhab.core.thing.ThingStatus;
43 import org.openhab.core.thing.ThingStatusDetail;
44 import org.openhab.core.thing.binding.BaseThingHandler;
45 import org.openhab.core.types.Command;
46 import org.openhab.core.types.RefreshType;
47 import org.openhab.core.types.State;
48 import org.openhab.core.types.UnDefType;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
51
52 import com.google.gson.Gson;
53
54 /**
55  * The {@link SpeedtestHandler} is responsible for handling commands, which are
56  * sent to one of the channels.
57  *
58  * @author Brian Homeyer - Initial contribution
59  */
60 @NonNullByDefault
61 public class SpeedtestHandler extends BaseThingHandler {
62     private final Logger logger = LoggerFactory.getLogger(SpeedtestHandler.class);
63     private SpeedtestConfiguration config = new SpeedtestConfiguration();
64     private Gson gson = new Gson();
65     private static Runtime rt = Runtime.getRuntime();
66     private long pollingInterval = 60;
67     private String serverID = "";
68     private final TimeZoneProvider timeZoneProvider;
69
70     private @Nullable ScheduledFuture<?> pollingJob;
71     public volatile boolean isRunning = false;
72
73     public static final String[] SHELL_WINDOWS = new String[] { "cmd" };
74     public static final String[] SHELL_NIX = new String[] { "sh", "bash", "zsh", "csh" };
75
76     private String speedTestCommand = "";
77     private static volatile OS os = OS.NOT_SET;
78     private static final Object LOCK = new Object();
79
80     private State pingJitter = UnDefType.NULL;
81     private State pingLatency = UnDefType.NULL;
82     private State downloadBandwidth = UnDefType.NULL;
83     private State downloadBytes = UnDefType.NULL;
84     private State downloadElapsed = UnDefType.NULL;
85     private State uploadBandwidth = UnDefType.NULL;
86     private State uploadBytes = UnDefType.NULL;
87     private State uploadElapsed = UnDefType.NULL;
88     private String isp = "";
89     private String interfaceInternalIp = "";
90     private String interfaceExternalIp = "";
91     private String resultUrl = "";
92     private State resultImage = UnDefType.NULL;
93     private String server = "";
94     private State timestamp = UnDefType.NULL;
95
96     /**
97      * Contains information about which operating system openHAB is running on.
98      */
99     public enum OS {
100         WINDOWS,
101         LINUX,
102         MAC,
103         SOLARIS,
104         UNKNOWN,
105         NOT_SET
106     }
107
108     public SpeedtestHandler(Thing thing, TimeZoneProvider timeZoneProvider) {
109         super(thing);
110         this.timeZoneProvider = timeZoneProvider;
111     }
112
113     @Override
114     public void handleCommand(ChannelUID channelUID, Command command) {
115         logger.debug("handleCommand channel: {} command: {}", channelUID, command);
116         String ch = channelUID.getId();
117         if (command instanceof RefreshType) {
118             if (!server.isBlank()) {
119                 updateChannels();
120             }
121             return;
122         }
123         if (ch.equals(SpeedtestBindingConstants.TRIGGER_TEST)) {
124             if (command instanceof OnOffType) {
125                 if (command == OnOffType.ON) {
126                     getSpeed();
127                     updateState(channelUID, OnOffType.OFF);
128                 }
129             }
130         }
131     }
132
133     @Override
134     public void initialize() {
135         config = getConfigAs(SpeedtestConfiguration.class);
136         pollingInterval = config.refreshInterval;
137         serverID = config.serverID;
138         if (!config.execPath.isEmpty()) {
139             speedTestCommand = config.execPath;
140         } else {
141             switch (getOperatingSystemType()) {
142                 case WINDOWS:
143                     speedTestCommand = "";
144                     break;
145                 case LINUX:
146                 case MAC:
147                 case SOLARIS:
148                     speedTestCommand = "/usr/bin/speedtest";
149                     break;
150                 default:
151                     speedTestCommand = "";
152             }
153         }
154
155         updateStatus(ThingStatus.UNKNOWN);
156
157         if (!checkConfig(speedTestCommand)) { // check the config
158             return;
159         }
160         if (!getSpeedTestVersion()) {
161             return;
162         }
163         getServerList();
164         updateStatus(ThingStatus.ONLINE);
165         isRunning = true;
166         onUpdate(); // Setup the scheduler
167     }
168
169     /**
170      * This is called to start the refresh job and also to reset that refresh job when a config change is done.
171      */
172     private void onUpdate() {
173         logger.debug("Polling Interval Set: {}", pollingInterval);
174         if (pollingInterval > 0) {
175             ScheduledFuture<?> pollingJob = this.pollingJob;
176             if (pollingJob == null || pollingJob.isCancelled()) {
177                 this.pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, pollingInterval,
178                         TimeUnit.MINUTES);
179             }
180         }
181     }
182
183     @Override
184     public void dispose() {
185         logger.debug("Disposing Speedtest Handler Thing");
186         isRunning = false;
187         ScheduledFuture<?> pollingJob = this.pollingJob;
188         if (pollingJob != null) {
189             pollingJob.cancel(true);
190             this.pollingJob = null;
191         }
192     }
193
194     /**
195      * Called when this thing gets it's configuration changed.
196      */
197     @Override
198     public void thingUpdated(Thing thing) {
199         dispose();
200         this.thing = thing;
201         initialize();
202     }
203
204     /**
205      * Polling event used to get speed data from speedtest
206      */
207     private Runnable pollingRunnable = () -> {
208         try {
209             getSpeed();
210         } catch (Exception e) {
211             logger.warn("An exception occurred while running Speedtest: '{}'", e.getMessage());
212             updateStatus(ThingStatus.OFFLINE);
213         }
214     };
215
216     /**
217      * Gets the version information from speedtest, this is really for debug in the event they change things
218      */
219     private boolean getSpeedTestVersion() {
220         String versionString = doExecuteRequest(" -V", String.class);
221         if ((versionString != null) && !versionString.isEmpty()) {
222             int newLI = versionString.indexOf(System.lineSeparator());
223             String versionLine = versionString.substring(0, newLI);
224             if (versionString.contains("Speedtest by Ookla")) {
225                 logger.debug("Speedtest Version: {}", versionLine);
226                 return true;
227             } else {
228                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
229                         "@text/offline.configuration-error.type");
230                 return false;
231             }
232         }
233         return false;
234     }
235
236     /**
237      * Get the server list from the speedtest command. Update the properties of the thing so the user
238      * can see the list of servers closest to them.
239      */
240     private boolean getServerList() {
241         String serverListTxt = "";
242         ResultsContainerServerList tmpCont = doExecuteRequest(" -f json -L", ResultsContainerServerList.class);
243         if (tmpCont != null) {
244             int id = 1;
245             Map<String, String> properties = editProperties();
246             for (ResultsContainerServerList.Server server : tmpCont.servers) {
247                 serverListTxt = "ID: " + server.id.toString() + ", " + server.host + " (" + server.location + ")";
248                 switch (id) {
249                     case 1:
250                         properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST1, serverListTxt);
251                         break;
252                     case 2:
253                         properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST2, serverListTxt);
254                         break;
255                     case 3:
256                         properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST3, serverListTxt);
257                         break;
258                     case 4:
259                         properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST4, serverListTxt);
260                         break;
261                     case 5:
262                         properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST5, serverListTxt);
263                         break;
264                     case 6:
265                         properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST6, serverListTxt);
266                         break;
267                     case 7:
268                         properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST7, serverListTxt);
269                         break;
270                     case 8:
271                         properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST8, serverListTxt);
272                         break;
273                     case 9:
274                         properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST9, serverListTxt);
275                         break;
276                     case 10:
277                         properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST10, serverListTxt);
278                         break;
279                 }
280                 id++;
281             }
282             updateProperties(properties);
283         }
284         return false;
285     }
286
287     /**
288      * Get the speedtest data and convert it from JSON and send it to update the channels.
289      */
290     private void getSpeed() {
291         logger.debug("Getting Speed Measurement");
292         String postCommand = "";
293         if (!serverID.isBlank()) {
294             postCommand = " -s " + serverID;
295         }
296         ResultContainer tmpCont = doExecuteRequest(" -f json --accept-license --accept-gdpr" + postCommand,
297                 ResultContainer.class);
298         if (tmpCont != null) {
299             if ("result".equals(tmpCont.getType())) {
300                 try {
301                     // timestamp format: "2023-07-20T19:34:54Z"
302                     ZonedDateTime zonedDateTime = ZonedDateTime.parse(tmpCont.getTimestamp())
303                             .withZoneSameInstant(timeZoneProvider.getTimeZone());
304                     timestamp = new DateTimeType(zonedDateTime);
305                 } catch (DateTimeParseException e) {
306                     timestamp = UnDefType.NULL;
307                     logger.debug("Exception: {}", e.getMessage());
308                 }
309                 try {
310                     pingJitter = new QuantityType<>(Double.parseDouble(tmpCont.getPing().getJitter()) / 1000.0,
311                             Units.SECOND);
312                 } catch (NumberFormatException e) {
313                     pingJitter = UnDefType.NULL;
314                     logger.debug("Exception: {}", e.getMessage());
315                 }
316                 try {
317                     pingLatency = new QuantityType<>(Double.parseDouble(tmpCont.getPing().getLatency()) / 1000.0,
318                             Units.SECOND);
319                 } catch (NumberFormatException e) {
320                     pingLatency = UnDefType.NULL;
321                     logger.debug("Exception: {}", e.getMessage());
322                 }
323                 try {
324                     downloadBandwidth = new QuantityType<>(
325                             Double.parseDouble(tmpCont.getDownload().getBandwidth()) / 125000.0,
326                             Units.MEGABIT_PER_SECOND);
327                 } catch (NumberFormatException e) {
328                     downloadBandwidth = UnDefType.NULL;
329                     logger.debug("Exception: {}", e.getMessage());
330                 }
331                 try {
332                     downloadBytes = new QuantityType<>(Double.parseDouble(tmpCont.getDownload().getBytes()),
333                             Units.BYTE);
334                 } catch (NumberFormatException e) {
335                     downloadBytes = UnDefType.NULL;
336                     logger.debug("Exception: {}", e.getMessage());
337                 }
338                 try {
339                     downloadElapsed = new QuantityType<>(
340                             Double.parseDouble(tmpCont.getDownload().getElapsed()) / 1000.0, Units.SECOND);
341                 } catch (NumberFormatException e) {
342                     downloadElapsed = UnDefType.NULL;
343                     logger.debug("Exception: {}", e.getMessage());
344                 }
345                 try {
346                     uploadBandwidth = new QuantityType<>(
347                             Double.parseDouble(tmpCont.getUpload().getBandwidth()) / 125000.0,
348                             Units.MEGABIT_PER_SECOND);
349                 } catch (NumberFormatException e) {
350                     uploadBandwidth = UnDefType.NULL;
351                     logger.debug("Exception: {}", e.getMessage());
352                 }
353                 try {
354                     uploadBytes = new QuantityType<>(Double.parseDouble(tmpCont.getUpload().getBytes()), Units.BYTE);
355                 } catch (NumberFormatException e) {
356                     uploadBytes = UnDefType.NULL;
357                     logger.debug("Exception: {}", e.getMessage());
358                 }
359                 try {
360                     uploadElapsed = new QuantityType<>(Double.parseDouble(tmpCont.getUpload().getElapsed()) / 1000.0,
361                             Units.SECOND);
362                 } catch (NumberFormatException e) {
363                     uploadElapsed = UnDefType.NULL;
364                     logger.debug("Exception: {}", e.getMessage());
365                 }
366                 isp = tmpCont.getIsp();
367                 interfaceInternalIp = tmpCont.getInterface().getInternalIp();
368                 interfaceExternalIp = tmpCont.getInterface().getExternalIp();
369                 resultUrl = tmpCont.getResult().getUrl();
370                 String url = String.valueOf(resultUrl) + ".png";
371                 logger.debug("Downloading result image from: {}", url);
372                 RawType image = HttpUtil.downloadImage(url);
373                 if (image != null) {
374                     resultImage = image;
375                 } else {
376                     resultImage = UnDefType.NULL;
377                 }
378
379                 server = tmpCont.getServer().getName() + " (" + tmpCont.getServer().getId().toString() + ") "
380                         + tmpCont.getServer().getLocation();
381                 updateChannels();
382             }
383         } else {
384             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
385                     "@text/offline.configuration-error.results");
386         }
387     }
388
389     private @Nullable <T> T doExecuteRequest(String arguments, Class<T> type) {
390         try {
391             String dataOut = executeCmd(speedTestCommand + arguments);
392             if (type != String.class) {
393                 @Nullable
394                 T obj = gson.fromJson(dataOut, type);
395                 return obj;
396             } else {
397                 @SuppressWarnings("unchecked")
398                 T obj = (T) dataOut;
399                 return obj;
400             }
401         } catch (Exception e) {
402             logger.debug("Exception: {}", e.getMessage());
403         }
404         return null;
405     }
406
407     /**
408      * Update the channels
409      */
410     private void updateChannels() {
411         logger.debug("Updating channels");
412
413         logger.debug("timestamp: {}", timestamp);
414         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.TIMESTAMP), timestamp);
415
416         logger.debug("pingJitter: {}", pingJitter);
417         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.PING_JITTER), pingJitter);
418
419         logger.debug("pingLatency: {}", pingLatency);
420         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.PING_LATENCY), pingLatency);
421
422         logger.debug("downloadBandwidth: {}", downloadBandwidth);
423         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.DOWNLOAD_BANDWIDTH),
424                 downloadBandwidth);
425
426         logger.debug("downloadBytes: {}", downloadBytes);
427         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.DOWNLOAD_BYTES), downloadBytes);
428
429         logger.debug("downloadElapsed: {}", downloadElapsed);
430         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.DOWNLOAD_ELAPSED), downloadElapsed);
431
432         logger.debug("uploadBandwidth: {}", uploadBandwidth);
433         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.UPLOAD_BANDWIDTH), uploadBandwidth);
434
435         logger.debug("uploadBytes: {}", uploadBytes);
436         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.UPLOAD_BYTES), uploadBytes);
437
438         logger.debug("uploadElapsed: {}", uploadElapsed);
439         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.UPLOAD_ELAPSED), uploadElapsed);
440
441         logger.debug("interfaceExternalIp: {}", interfaceExternalIp);
442         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.INTERFACE_EXTERNALIP),
443                 new StringType(interfaceExternalIp));
444
445         logger.debug("interfaceInternalIp: {}", interfaceInternalIp);
446         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.INTERFACE_INTERNALIP),
447                 new StringType(interfaceInternalIp));
448
449         logger.debug("isp: {}", isp);
450         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.ISP), new StringType(isp));
451
452         logger.debug("resultUrl: {}", resultUrl);
453         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.RESULT_URL),
454                 new StringType(resultUrl));
455
456         logger.debug("resultImage: <RawType>");
457         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.RESULT_IMAGE), resultImage);
458
459         logger.debug("server: {}", server);
460         updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.SERVER), new StringType(server));
461     }
462
463     /**
464      * Checks to make sure the executable for speedtest is valid
465      */
466     public boolean checkConfig(String execPath) {
467         File file = new File(execPath);
468         if (!checkFileExists(file)) { // Check if entered path exists
469             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
470                     "@text/offline.configuration-error.file");
471             return false;
472         }
473
474         if (!checkFileExecutable(file)) { // Check if speedtest is executable
475             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
476                     "@text/offline.configuration-error.exec");
477             return false;
478         }
479         return true;
480     }
481
482     /**
483      * Executes a given command and returns back the String data of stdout.
484      */
485     private String executeCmd(String commandLine) {
486         int timeOut = 60000;
487         String[] cmdArray;
488         String[] shell;
489         logger.debug("Passing to shell for parsing command.");
490         switch (getOperatingSystemType()) {
491             case WINDOWS:
492                 shell = SHELL_WINDOWS;
493                 logger.debug("OS: WINDOWS ({})", getOperatingSystemName());
494                 cmdArray = createCmdArray(shell, "/c", commandLine);
495                 break;
496             case LINUX:
497             case MAC:
498             case SOLARIS:
499                 // assume sh is present, should all be POSIX-compliant
500                 shell = SHELL_NIX;
501                 logger.debug("OS: *NIX ({})", getOperatingSystemName());
502                 cmdArray = createCmdArray(shell, "-c", commandLine);
503                 break;
504             default:
505                 logger.debug("OS: Unknown ({})", getOperatingSystemName());
506                 return "";
507         }
508
509         if (cmdArray.length == 0) {
510             logger.debug("Empty command received, not executing");
511             return "";
512         }
513
514         logger.debug("The command to be executed will be '{}'", Arrays.asList(cmdArray));
515
516         Process proc;
517         try {
518             proc = rt.exec(cmdArray);
519         } catch (Exception e) {
520             logger.debug("An exception occurred while executing '{}': '{}'", Arrays.asList(cmdArray), e.getMessage());
521             return "";
522         }
523
524         StringBuilder outputBuilder = new StringBuilder();
525         try (InputStreamReader isr = new InputStreamReader(proc.getInputStream());
526                 BufferedReader br = new BufferedReader(isr)) {
527             String line;
528             while ((line = br.readLine()) != null) {
529                 outputBuilder.append(line).append(System.lineSeparator());
530                 logger.debug("Exec [{}]: '{}'", "OUTPUT", line);
531             }
532         } catch (IOException e) {
533             logger.warn("An exception occurred while reading the stdout when executing '{}': '{}'", commandLine,
534                     e.getMessage());
535         }
536
537         boolean exitVal = false;
538         try {
539             exitVal = proc.waitFor(timeOut, TimeUnit.MILLISECONDS);
540         } catch (InterruptedException e) {
541             logger.debug("An exception occurred while waiting for the process ('{}') to finish: '{}'", commandLine,
542                     e.getMessage());
543         }
544
545         if (!exitVal) {
546             logger.debug("Forcibly termininating the process ('{}') after a timeout of {} ms", commandLine, timeOut);
547             proc.destroyForcibly();
548         }
549         return outputBuilder.toString();
550     }
551
552     /**
553      * Transforms the command string into an array.
554      * Either invokes the shell and passes using the "c" option
555      * or (if command already starts with one of the shells) splits by space.
556      *
557      * @param shell (path), picks to first one to execute the command
558      * @param cOption "c"-option string
559      * @param commandLine to execute
560      * @return command array
561      */
562     protected String[] createCmdArray(String[] shell, String cOption, String commandLine) {
563         boolean startsWithShell = false;
564         for (String sh : shell) {
565             if (commandLine.startsWith(sh + " ")) {
566                 startsWithShell = true;
567                 break;
568             }
569         }
570
571         if (!startsWithShell) {
572             return new String[] { shell[0], cOption, commandLine };
573         } else {
574             logger.debug("Splitting by spaces");
575             try {
576                 return commandLine.split(" ");
577             } catch (PatternSyntaxException e) {
578                 logger.warn("An exception occurred while splitting '{}': '{}'", commandLine, e.getMessage());
579                 return new String[] {};
580             }
581         }
582     }
583
584     public static OS getOperatingSystemType() {
585         synchronized (LOCK) {
586             if (os == OS.NOT_SET) {
587                 String operSys = System.getProperty("os.name");
588                 if (operSys == null) {
589                     os = OS.UNKNOWN;
590                 } else {
591                     operSys = operSys.toLowerCase();
592
593                     if (operSys.contains("win")) {
594                         os = OS.WINDOWS;
595                     } else if (operSys.contains("nix") || operSys.contains("nux") || operSys.contains("aix")) {
596                         os = OS.LINUX;
597                     } else if (operSys.contains("mac")) {
598                         os = OS.MAC;
599                     } else if (operSys.contains("sunos")) {
600                         os = OS.SOLARIS;
601                     } else {
602                         os = OS.UNKNOWN;
603                     }
604                 }
605             }
606         }
607         return os;
608     }
609
610     public static String getOperatingSystemName() {
611         String osname = System.getProperty("os.name");
612         return osname != null ? osname : "unknown";
613     }
614
615     public boolean checkFileExists(File file) {
616         return file.exists() && !file.isDirectory();
617     }
618
619     public boolean checkFileExecutable(File file) {
620         return file.canExecute();
621     }
622 }