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.util.Arrays;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24 import java.util.regex.PatternSyntaxException;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.speedtest.internal.dto.ResultContainer;
29 import org.openhab.binding.speedtest.internal.dto.ResultsContainerServerList;
30 import org.openhab.core.library.types.OnOffType;
31 import org.openhab.core.library.types.QuantityType;
32 import org.openhab.core.library.types.StringType;
33 import org.openhab.core.library.unit.Units;
34 import org.openhab.core.thing.ChannelUID;
35 import org.openhab.core.thing.Thing;
36 import org.openhab.core.thing.ThingStatus;
37 import org.openhab.core.thing.ThingStatusDetail;
38 import org.openhab.core.thing.binding.BaseThingHandler;
39 import org.openhab.core.types.Command;
40 import org.openhab.core.types.RefreshType;
41 import org.openhab.core.types.State;
42 import org.slf4j.Logger;
43 import org.slf4j.LoggerFactory;
45 import com.google.gson.Gson;
48 * The {@link SpeedtestHandler} is responsible for handling commands, which are
49 * sent to one of the channels.
51 * @author Brian Homeyer - Initial contribution
54 public class SpeedtestHandler extends BaseThingHandler {
55 private final Logger logger = LoggerFactory.getLogger(SpeedtestHandler.class);
56 private SpeedtestConfiguration config = new SpeedtestConfiguration();
57 private Gson gson = new Gson();
58 private static Runtime rt = Runtime.getRuntime();
59 private long pollingInterval = 60;
60 private String serverID = "";
62 private @Nullable ScheduledFuture<?> pollingJob;
63 public volatile boolean isRunning = false;
65 public static final String[] SHELL_WINDOWS = new String[] { "cmd" };
66 public static final String[] SHELL_NIX = new String[] { "sh", "bash", "zsh", "csh" };
68 private String speedTestCommand = "";
69 private static volatile OS os = OS.NOT_SET;
70 private static final Object LOCK = new Object();
72 private String pingJitter = "";
73 private String pingLatency = "";
74 private String downloadBandwidth = "";
75 private String downloadBytes = "";
76 private String downloadElapsed = "";
77 private String uploadBandwidth = "";
78 private String uploadBytes = "";
79 private String uploadElapsed = "";
80 private String isp = "";
81 private String interfaceInternalIp = "";
82 private String interfaceExternalIp = "";
83 private String resultUrl = "";
84 private String server = "";
87 * Contains information about which operating system openHAB is running on.
98 public SpeedtestHandler(Thing thing) {
103 public void handleCommand(ChannelUID channelUID, Command command) {
104 logger.debug("handleCommand channel: {} command: {}", channelUID, command);
105 String ch = channelUID.getId();
106 if (command instanceof RefreshType) {
107 if (!server.isBlank()) {
112 if (ch.equals(SpeedtestBindingConstants.TRIGGER_TEST)) {
113 if (command instanceof OnOffType) {
114 if (command == OnOffType.ON) {
116 updateState(channelUID, OnOffType.OFF);
123 public void initialize() {
124 config = getConfigAs(SpeedtestConfiguration.class);
125 pollingInterval = config.refreshInterval;
126 serverID = config.serverID;
127 if (!config.execPath.isEmpty()) {
128 speedTestCommand = config.execPath;
130 switch (getOperatingSystemType()) {
132 speedTestCommand = "";
137 speedTestCommand = "/usr/bin/speedtest";
140 speedTestCommand = "";
144 updateStatus(ThingStatus.UNKNOWN);
146 if (!checkConfig(speedTestCommand)) { // check the config
149 if (!getSpeedTestVersion()) {
153 updateStatus(ThingStatus.ONLINE);
155 onUpdate(); // Setup the scheduler
159 * This is called to start the refresh job and also to reset that refresh job when a config change is done.
161 private void onUpdate() {
162 logger.debug("Polling Interval Set: {}", pollingInterval);
163 if (pollingInterval > 0) {
164 ScheduledFuture<?> pollingJob = this.pollingJob;
165 if (pollingJob == null || pollingJob.isCancelled()) {
166 this.pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, pollingInterval,
173 public void dispose() {
174 logger.debug("Disposing Speedtest Handler Thing");
176 ScheduledFuture<?> pollingJob = this.pollingJob;
177 if (pollingJob != null) {
178 pollingJob.cancel(true);
179 this.pollingJob = null;
184 * Called when this thing gets it's configuration changed.
187 public void thingUpdated(Thing thing) {
194 * Polling event used to get speed data from speedtest
196 private Runnable pollingRunnable = () -> {
199 } catch (Exception e) {
200 logger.warn("An exception occurred while running Speedtest: '{}'", e.getMessage());
201 updateStatus(ThingStatus.OFFLINE);
206 * Gets the version information from speedtest, this is really for debug in the event they change things
208 private boolean getSpeedTestVersion() {
209 String versionString = doExecuteRequest(" -V", String.class);
210 if ((versionString != null) && !versionString.isEmpty()) {
211 int newLI = versionString.indexOf(System.lineSeparator());
212 String versionLine = versionString.substring(0, newLI);
213 if (versionString.indexOf("Speedtest by Ookla") > -1) {
214 logger.debug("Speedtest Version: {}", versionLine);
217 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
218 "@text/offline.configuration-error.type");
226 * Get the server list from the speedtest command. Update the properties of the thing so the user
227 * can see the list of servers closest to them.
229 private boolean getServerList() {
230 String serverListTxt = "";
231 ResultsContainerServerList tmpCont = doExecuteRequest(" -f json -L", ResultsContainerServerList.class);
232 if (tmpCont != null) {
234 Map<String, String> properties = editProperties();
235 for (ResultsContainerServerList.Server server : tmpCont.servers) {
236 serverListTxt = "ID: " + server.id.toString() + ", " + server.host + " (" + server.location + ")";
239 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST1, serverListTxt);
242 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST2, serverListTxt);
245 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST3, serverListTxt);
248 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST4, serverListTxt);
251 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST5, serverListTxt);
254 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST6, serverListTxt);
257 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST7, serverListTxt);
260 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST8, serverListTxt);
263 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST9, serverListTxt);
266 properties.replace(SpeedtestBindingConstants.PROPERTY_SERVER_LIST10, serverListTxt);
271 updateProperties(properties);
277 * Get the speedtest data and convert it from JSON and send it to update the channels.
279 private void getSpeed() {
280 logger.debug("Getting Speed Measurement");
281 String postCommand = "";
282 if (!serverID.isBlank()) {
283 postCommand = " -s " + serverID;
285 ResultContainer tmpCont = doExecuteRequest(" -f json --accept-license --accept-gdpr" + postCommand,
286 ResultContainer.class);
287 if (tmpCont != null) {
288 if (tmpCont.getType().equals("result")) {
289 pingJitter = tmpCont.getPing().getJitter();
290 pingLatency = tmpCont.getPing().getLatency();
291 downloadBandwidth = tmpCont.getDownload().getBandwidth();
292 downloadBytes = tmpCont.getDownload().getBytes();
293 downloadElapsed = tmpCont.getDownload().getElapsed();
294 uploadBandwidth = tmpCont.getUpload().getBandwidth();
295 uploadBytes = tmpCont.getUpload().getBytes();
296 uploadElapsed = tmpCont.getUpload().getElapsed();
297 isp = tmpCont.getIsp();
298 interfaceInternalIp = tmpCont.getInterface().getInternalIp();
299 interfaceExternalIp = tmpCont.getInterface().getExternalIp();
300 resultUrl = tmpCont.getResult().getUrl();
301 server = tmpCont.getServer().getName() + " (" + tmpCont.getServer().getId().toString() + ") "
302 + tmpCont.getServer().getLocation();
306 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
307 "@text/offline.configuration-error.results");
311 private @Nullable <T> T doExecuteRequest(String arguments, Class<T> type) {
313 String dataOut = executeCmd(speedTestCommand + arguments);
314 if (type != String.class) {
316 T obj = gson.fromJson(dataOut, type);
319 @SuppressWarnings("unchecked")
323 } catch (Exception e) {
324 logger.debug("Exception: {}", e.getMessage());
330 * Update the channels
332 private void updateChannels() {
333 logger.debug("Updating channels");
335 State newState = new QuantityType<>(Double.parseDouble(pingJitter) / 1000.0, Units.SECOND);
336 logger.debug("pingJitter: {}", newState);
337 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.PING_JITTER), newState);
339 newState = new QuantityType<>(Double.parseDouble(pingLatency) / 1000.0, Units.SECOND);
340 logger.debug("pingLatency: {}", newState);
341 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.PING_LATENCY), newState);
343 newState = new QuantityType<>(Double.parseDouble(downloadBandwidth) / 125000.0, Units.MEGABIT_PER_SECOND);
344 logger.debug("downloadBandwidth: {}", newState);
345 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.DOWNLOAD_BANDWIDTH), newState);
347 newState = new QuantityType<>(Double.parseDouble(downloadBytes), Units.BYTE);
348 logger.debug("downloadBytes: {}", newState);
349 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.DOWNLOAD_BYTES), newState);
351 newState = new QuantityType<>(Double.parseDouble(downloadElapsed) / 1000.0, Units.SECOND);
352 logger.debug("downloadElapsed: {}", newState);
353 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.DOWNLOAD_ELAPSED), newState);
355 newState = new QuantityType<>(Double.parseDouble(uploadBandwidth) / 125000.0, Units.MEGABIT_PER_SECOND);
356 logger.debug("uploadBandwidth: {}", newState);
357 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.UPLOAD_BANDWIDTH), newState);
359 newState = new QuantityType<>(Double.parseDouble(uploadBytes), Units.BYTE);
360 logger.debug("uploadBytes: {}", newState);
361 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.UPLOAD_BYTES), newState);
363 newState = new QuantityType<>(Double.parseDouble(uploadElapsed) / 1000.0, Units.SECOND);
364 logger.debug("uploadElapsed: {}", newState);
365 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.UPLOAD_ELAPSED), newState);
367 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.INTERFACE_EXTERNALIP),
368 new StringType(String.valueOf(interfaceExternalIp)));
369 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.INTERFACE_INTERNALIP),
370 new StringType(String.valueOf(interfaceInternalIp)));
371 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.ISP),
372 new StringType(String.valueOf(isp)));
373 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.RESULT_URL),
374 new StringType(String.valueOf(resultUrl)));
375 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.SERVER),
376 new StringType(String.valueOf(server)));
380 * Checks to make sure the executable for speedtest is valid
382 public boolean checkConfig(String execPath) {
383 File file = new File(execPath);
384 if (!checkFileExists(file)) { // Check if entered path exists
385 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
386 "@text/offline.configuration-error.file");
390 if (!checkFileExecutable(file)) { // Check if speedtest is executable
391 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
392 "@text/offline.configuration-error.exec");
399 * Executes a given command and returns back the String data of stdout.
401 private String executeCmd(String commandLine) {
405 logger.debug("Passing to shell for parsing command.");
406 switch (getOperatingSystemType()) {
408 shell = SHELL_WINDOWS;
409 logger.debug("OS: WINDOWS ({})", getOperatingSystemName());
410 cmdArray = createCmdArray(shell, "/c", commandLine);
415 // assume sh is present, should all be POSIX-compliant
417 logger.debug("OS: *NIX ({})", getOperatingSystemName());
418 cmdArray = createCmdArray(shell, "-c", commandLine);
421 logger.debug("OS: Unknown ({})", getOperatingSystemName());
425 if (cmdArray.length == 0) {
426 logger.debug("Empty command received, not executing");
430 logger.debug("The command to be executed will be '{}'", Arrays.asList(cmdArray));
434 proc = rt.exec(cmdArray);
435 } catch (Exception e) {
436 logger.debug("An exception occurred while executing '{}': '{}'", Arrays.asList(cmdArray), e.getMessage());
440 StringBuilder outputBuilder = new StringBuilder();
441 try (InputStreamReader isr = new InputStreamReader(proc.getInputStream());
442 BufferedReader br = new BufferedReader(isr)) {
444 while ((line = br.readLine()) != null) {
445 outputBuilder.append(line).append(System.lineSeparator());
446 logger.debug("Exec [{}]: '{}'", "OUTPUT", line);
448 } catch (IOException e) {
449 logger.warn("An exception occurred while reading the stdout when executing '{}': '{}'", commandLine,
453 boolean exitVal = false;
455 exitVal = proc.waitFor(timeOut, TimeUnit.MILLISECONDS);
456 } catch (InterruptedException e) {
457 logger.debug("An exception occurred while waiting for the process ('{}') to finish: '{}'", commandLine,
462 logger.debug("Forcibly termininating the process ('{}') after a timeout of {} ms", commandLine, timeOut);
463 proc.destroyForcibly();
465 return outputBuilder.toString();
469 * Transforms the command string into an array.
470 * Either invokes the shell and passes using the "c" option
471 * or (if command already starts with one of the shells) splits by space.
473 * @param shell (path), picks to first one to execute the command
474 * @param cOption "c"-option string
475 * @param commandLine to execute
476 * @return command array
478 protected String[] createCmdArray(String[] shell, String cOption, String commandLine) {
479 boolean startsWithShell = false;
480 for (String sh : shell) {
481 if (commandLine.startsWith(sh + " ")) {
482 startsWithShell = true;
487 if (!startsWithShell) {
488 return new String[] { shell[0], cOption, commandLine };
490 logger.debug("Splitting by spaces");
492 String[] splitCmd = commandLine.split(" ");
494 } catch (PatternSyntaxException e) {
495 logger.warn("An exception occurred while splitting '{}': '{}'", commandLine, e.getMessage());
496 return new String[] {};
501 public static OS getOperatingSystemType() {
502 synchronized (LOCK) {
503 if (os == OS.NOT_SET) {
504 String operSys = System.getProperty("os.name");
505 if (operSys == null) {
508 operSys = operSys.toLowerCase();
510 if (operSys.contains("win")) {
512 } else if (operSys.contains("nix") || operSys.contains("nux") || operSys.contains("aix")) {
514 } else if (operSys.contains("mac")) {
516 } else if (operSys.contains("sunos")) {
527 public static String getOperatingSystemName() {
528 String osname = System.getProperty("os.name");
529 return osname != null ? osname : "unknown";
532 public boolean checkFileExists(File file) {
533 return file.exists() && !file.isDirectory();
536 public boolean checkFileExecutable(File file) {
537 return file.canExecute();