2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
14 package org.openhab.binding.speedtest.internal;
16 import java.io.BufferedReader;
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;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26 import java.util.regex.PatternSyntaxException;
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;
52 import com.google.gson.Gson;
55 * The {@link SpeedtestHandler} is responsible for handling commands, which are
56 * sent to one of the channels.
58 * @author Brian Homeyer - Initial contribution
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;
70 private @Nullable ScheduledFuture<?> pollingJob;
71 public volatile boolean isRunning = false;
73 public static final String[] SHELL_WINDOWS = new String[] { "cmd" };
74 public static final String[] SHELL_NIX = new String[] { "sh", "bash", "zsh", "csh" };
76 private String speedTestCommand = "";
77 private static volatile OS os = OS.NOT_SET;
78 private static final Object LOCK = new Object();
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;
97 * Contains information about which operating system openHAB is running on.
108 public SpeedtestHandler(Thing thing, TimeZoneProvider timeZoneProvider) {
110 this.timeZoneProvider = timeZoneProvider;
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()) {
123 if (ch.equals(SpeedtestBindingConstants.TRIGGER_TEST)) {
124 if (command instanceof OnOffType) {
125 if (command == OnOffType.ON) {
127 updateState(channelUID, OnOffType.OFF);
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;
141 switch (getOperatingSystemType()) {
143 speedTestCommand = "";
148 speedTestCommand = "/usr/bin/speedtest";
151 speedTestCommand = "";
155 updateStatus(ThingStatus.UNKNOWN);
157 if (!checkConfig(speedTestCommand)) { // check the config
160 if (!getSpeedTestVersion()) {
164 updateStatus(ThingStatus.ONLINE);
166 onUpdate(); // Setup the scheduler
170 * This is called to start the refresh job and also to reset that refresh job when a config change is done.
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,
184 public void dispose() {
185 logger.debug("Disposing Speedtest Handler Thing");
187 ScheduledFuture<?> pollingJob = this.pollingJob;
188 if (pollingJob != null) {
189 pollingJob.cancel(true);
190 this.pollingJob = null;
195 * Called when this thing gets it's configuration changed.
198 public void thingUpdated(Thing thing) {
205 * Polling event used to get speed data from speedtest
207 private Runnable pollingRunnable = () -> {
210 } catch (Exception e) {
211 logger.warn("An exception occurred while running Speedtest: '{}'", e.getMessage());
212 updateStatus(ThingStatus.OFFLINE);
217 * Gets the version information from speedtest, this is really for debug in the event they change things
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);
228 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
229 "@text/offline.configuration-error.type");
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.
240 private boolean getServerList() {
241 String serverListTxt = "";
242 ResultsContainerServerList tmpCont = doExecuteRequest(" -f json -L", ResultsContainerServerList.class);
243 if (tmpCont != null) {
245 Map<String, String> properties = editProperties();
246 for (ResultsContainerServerList.Server server : tmpCont.servers) {
247 serverListTxt = "ID: " + server.id.toString() + ", " + server.host + " (" + server.location + ")";
250 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST1, serverListTxt);
253 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST2, serverListTxt);
256 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST3, serverListTxt);
259 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST4, serverListTxt);
262 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST5, serverListTxt);
265 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST6, serverListTxt);
268 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST7, serverListTxt);
271 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST8, serverListTxt);
274 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST9, serverListTxt);
277 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST10, serverListTxt);
282 updateProperties(properties);
288 * Get the speedtest data and convert it from JSON and send it to update the channels.
290 private void getSpeed() {
291 logger.debug("Getting Speed Measurement");
292 String postCommand = "";
293 if (!serverID.isBlank()) {
294 postCommand = " -s " + serverID;
296 ResultContainer tmpCont = doExecuteRequest(" -f json --accept-license --accept-gdpr" + postCommand,
297 ResultContainer.class);
298 if (tmpCont != null) {
299 if ("result".equals(tmpCont.getType())) {
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());
310 pingJitter = new QuantityType<>(Double.parseDouble(tmpCont.getPing().getJitter()) / 1000.0,
312 } catch (NumberFormatException e) {
313 pingJitter = UnDefType.NULL;
314 logger.debug("Exception: {}", e.getMessage());
317 pingLatency = new QuantityType<>(Double.parseDouble(tmpCont.getPing().getLatency()) / 1000.0,
319 } catch (NumberFormatException e) {
320 pingLatency = UnDefType.NULL;
321 logger.debug("Exception: {}", e.getMessage());
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());
332 downloadBytes = new QuantityType<>(Double.parseDouble(tmpCont.getDownload().getBytes()),
334 } catch (NumberFormatException e) {
335 downloadBytes = UnDefType.NULL;
336 logger.debug("Exception: {}", e.getMessage());
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());
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());
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());
360 uploadElapsed = new QuantityType<>(Double.parseDouble(tmpCont.getUpload().getElapsed()) / 1000.0,
362 } catch (NumberFormatException e) {
363 uploadElapsed = UnDefType.NULL;
364 logger.debug("Exception: {}", e.getMessage());
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);
376 resultImage = UnDefType.NULL;
379 server = tmpCont.getServer().getName() + " (" + tmpCont.getServer().getId().toString() + ") "
380 + tmpCont.getServer().getLocation();
384 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
385 "@text/offline.configuration-error.results");
389 private @Nullable <T> T doExecuteRequest(String arguments, Class<T> type) {
391 String dataOut = executeCmd(speedTestCommand + arguments);
392 if (type != String.class) {
394 T obj = gson.fromJson(dataOut, type);
397 @SuppressWarnings("unchecked")
401 } catch (Exception e) {
402 logger.debug("Exception: {}", e.getMessage());
408 * Update the channels
410 private void updateChannels() {
411 logger.debug("Updating channels");
413 logger.debug("timestamp: {}", timestamp);
414 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.TIMESTAMP), timestamp);
416 logger.debug("pingJitter: {}", pingJitter);
417 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.PING_JITTER), pingJitter);
419 logger.debug("pingLatency: {}", pingLatency);
420 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.PING_LATENCY), pingLatency);
422 logger.debug("downloadBandwidth: {}", downloadBandwidth);
423 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.DOWNLOAD_BANDWIDTH),
426 logger.debug("downloadBytes: {}", downloadBytes);
427 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.DOWNLOAD_BYTES), downloadBytes);
429 logger.debug("downloadElapsed: {}", downloadElapsed);
430 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.DOWNLOAD_ELAPSED), downloadElapsed);
432 logger.debug("uploadBandwidth: {}", uploadBandwidth);
433 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.UPLOAD_BANDWIDTH), uploadBandwidth);
435 logger.debug("uploadBytes: {}", uploadBytes);
436 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.UPLOAD_BYTES), uploadBytes);
438 logger.debug("uploadElapsed: {}", uploadElapsed);
439 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.UPLOAD_ELAPSED), uploadElapsed);
441 logger.debug("interfaceExternalIp: {}", interfaceExternalIp);
442 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.INTERFACE_EXTERNALIP),
443 new StringType(interfaceExternalIp));
445 logger.debug("interfaceInternalIp: {}", interfaceInternalIp);
446 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.INTERFACE_INTERNALIP),
447 new StringType(interfaceInternalIp));
449 logger.debug("isp: {}", isp);
450 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.ISP), new StringType(isp));
452 logger.debug("resultUrl: {}", resultUrl);
453 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.RESULT_URL),
454 new StringType(resultUrl));
456 logger.debug("resultImage: <RawType>");
457 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.RESULT_IMAGE), resultImage);
459 logger.debug("server: {}", server);
460 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.SERVER), new StringType(server));
464 * Checks to make sure the executable for speedtest is valid
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");
474 if (!checkFileExecutable(file)) { // Check if speedtest is executable
475 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
476 "@text/offline.configuration-error.exec");
483 * Executes a given command and returns back the String data of stdout.
485 private String executeCmd(String commandLine) {
489 logger.debug("Passing to shell for parsing command.");
490 switch (getOperatingSystemType()) {
492 shell = SHELL_WINDOWS;
493 logger.debug("OS: WINDOWS ({})", getOperatingSystemName());
494 cmdArray = createCmdArray(shell, "/c", commandLine);
499 // assume sh is present, should all be POSIX-compliant
501 logger.debug("OS: *NIX ({})", getOperatingSystemName());
502 cmdArray = createCmdArray(shell, "-c", commandLine);
505 logger.debug("OS: Unknown ({})", getOperatingSystemName());
509 if (cmdArray.length == 0) {
510 logger.debug("Empty command received, not executing");
514 logger.debug("The command to be executed will be '{}'", Arrays.asList(cmdArray));
518 proc = rt.exec(cmdArray);
519 } catch (Exception e) {
520 logger.debug("An exception occurred while executing '{}': '{}'", Arrays.asList(cmdArray), e.getMessage());
524 StringBuilder outputBuilder = new StringBuilder();
525 try (InputStreamReader isr = new InputStreamReader(proc.getInputStream());
526 BufferedReader br = new BufferedReader(isr)) {
528 while ((line = br.readLine()) != null) {
529 outputBuilder.append(line).append(System.lineSeparator());
530 logger.debug("Exec [{}]: '{}'", "OUTPUT", line);
532 } catch (IOException e) {
533 logger.warn("An exception occurred while reading the stdout when executing '{}': '{}'", commandLine,
537 boolean exitVal = false;
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,
546 logger.debug("Forcibly termininating the process ('{}') after a timeout of {} ms", commandLine, timeOut);
547 proc.destroyForcibly();
549 return outputBuilder.toString();
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.
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
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;
571 if (!startsWithShell) {
572 return new String[] { shell[0], cOption, commandLine };
574 logger.debug("Splitting by spaces");
576 return commandLine.split(" ");
577 } catch (PatternSyntaxException e) {
578 logger.warn("An exception occurred while splitting '{}': '{}'", commandLine, e.getMessage());
579 return new String[] {};
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) {
591 operSys = operSys.toLowerCase();
593 if (operSys.contains("win")) {
595 } else if (operSys.contains("nix") || operSys.contains("nux") || operSys.contains("aix")) {
597 } else if (operSys.contains("mac")) {
599 } else if (operSys.contains("sunos")) {
610 public static String getOperatingSystemName() {
611 String osname = System.getProperty("os.name");
612 return osname != null ? osname : "unknown";
615 public boolean checkFileExists(File file) {
616 return file.exists() && !file.isDirectory();
619 public boolean checkFileExecutable(File file) {
620 return file.canExecute();