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.contains("Speedtest by Ookla")) {
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 ("result".equals(tmpCont.getType())) {
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(interfaceExternalIp));
369 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.INTERFACE_INTERNALIP),
370 new StringType(interfaceInternalIp));
371 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.ISP), new StringType(isp));
372 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.RESULT_URL),
373 new StringType(resultUrl));
374 updateState(new ChannelUID(getThing().getUID(), SpeedtestBindingConstants.SERVER), new StringType(server));
378 * Checks to make sure the executable for speedtest is valid
380 public boolean checkConfig(String execPath) {
381 File file = new File(execPath);
382 if (!checkFileExists(file)) { // Check if entered path exists
383 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
384 "@text/offline.configuration-error.file");
388 if (!checkFileExecutable(file)) { // Check if speedtest is executable
389 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
390 "@text/offline.configuration-error.exec");
397 * Executes a given command and returns back the String data of stdout.
399 private String executeCmd(String commandLine) {
403 logger.debug("Passing to shell for parsing command.");
404 switch (getOperatingSystemType()) {
406 shell = SHELL_WINDOWS;
407 logger.debug("OS: WINDOWS ({})", getOperatingSystemName());
408 cmdArray = createCmdArray(shell, "/c", commandLine);
413 // assume sh is present, should all be POSIX-compliant
415 logger.debug("OS: *NIX ({})", getOperatingSystemName());
416 cmdArray = createCmdArray(shell, "-c", commandLine);
419 logger.debug("OS: Unknown ({})", getOperatingSystemName());
423 if (cmdArray.length == 0) {
424 logger.debug("Empty command received, not executing");
428 logger.debug("The command to be executed will be '{}'", Arrays.asList(cmdArray));
432 proc = rt.exec(cmdArray);
433 } catch (Exception e) {
434 logger.debug("An exception occurred while executing '{}': '{}'", Arrays.asList(cmdArray), e.getMessage());
438 StringBuilder outputBuilder = new StringBuilder();
439 try (InputStreamReader isr = new InputStreamReader(proc.getInputStream());
440 BufferedReader br = new BufferedReader(isr)) {
442 while ((line = br.readLine()) != null) {
443 outputBuilder.append(line).append(System.lineSeparator());
444 logger.debug("Exec [{}]: '{}'", "OUTPUT", line);
446 } catch (IOException e) {
447 logger.warn("An exception occurred while reading the stdout when executing '{}': '{}'", commandLine,
451 boolean exitVal = false;
453 exitVal = proc.waitFor(timeOut, TimeUnit.MILLISECONDS);
454 } catch (InterruptedException e) {
455 logger.debug("An exception occurred while waiting for the process ('{}') to finish: '{}'", commandLine,
460 logger.debug("Forcibly termininating the process ('{}') after a timeout of {} ms", commandLine, timeOut);
461 proc.destroyForcibly();
463 return outputBuilder.toString();
467 * Transforms the command string into an array.
468 * Either invokes the shell and passes using the "c" option
469 * or (if command already starts with one of the shells) splits by space.
471 * @param shell (path), picks to first one to execute the command
472 * @param cOption "c"-option string
473 * @param commandLine to execute
474 * @return command array
476 protected String[] createCmdArray(String[] shell, String cOption, String commandLine) {
477 boolean startsWithShell = false;
478 for (String sh : shell) {
479 if (commandLine.startsWith(sh + " ")) {
480 startsWithShell = true;
485 if (!startsWithShell) {
486 return new String[] { shell[0], cOption, commandLine };
488 logger.debug("Splitting by spaces");
490 return commandLine.split(" ");
491 } catch (PatternSyntaxException e) {
492 logger.warn("An exception occurred while splitting '{}': '{}'", commandLine, e.getMessage());
493 return new String[] {};
498 public static OS getOperatingSystemType() {
499 synchronized (LOCK) {
500 if (os == OS.NOT_SET) {
501 String operSys = System.getProperty("os.name");
502 if (operSys == null) {
505 operSys = operSys.toLowerCase();
507 if (operSys.contains("win")) {
509 } else if (operSys.contains("nix") || operSys.contains("nux") || operSys.contains("aix")) {
511 } else if (operSys.contains("mac")) {
513 } else if (operSys.contains("sunos")) {
524 public static String getOperatingSystemName() {
525 String osname = System.getProperty("os.name");
526 return osname != null ? osname : "unknown";
529 public boolean checkFileExists(File file) {
530 return file.exists() && !file.isDirectory();
533 public boolean checkFileExecutable(File file) {
534 return file.canExecute();