Add a common backend for running librespeed-cli measurements on a router.
The package provides a locked measurement runner, live progress state, and
an rpcd ucode plugin exposing start/stop/status/result/history/config over
ubus. Scheduled measurements are managed through cron and can run within a
configurable time window. Optional daily aggregation keeps longer-term
history compact.
Raw measurements are kept in JSONL for a configurable retention period.
When persistent archive storage is configured, completed days are reduced
to daily min/avg/max values.
Signed-off-by: Josef Schlehofer <redacted>
--- /dev/null
+Valid-License-Identifier: GPL-2.0-only
+Valid-License-Identifier: GPL-2.0
+Valid-License-Identifier: GPL-2.0-or-later
+Valid-License-Identifier: GPL-2.0+
+SPDX-URL: https://spdx.org/licenses/GPL-2.0.html
+Usage-Guide:
+ To use this license in source code, put one of the following SPDX
+ tag/value pairs into a comment according to the placement
+ guidelines in the licensing rules documentation.
+ For 'GNU General Public License (GPL) version 2 only' use:
+ SPDX-License-Identifier: GPL-2.0-only
+ For 'GNU General Public License (GPL) version 2 or any later version' use:
+ SPDX-License-Identifier: GPL-2.0-or-later
+License-Text:
+
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.
+ 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Library General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+\f
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+\f
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+\f
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+\f
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+\f
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Ty Coon>, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Library General
+Public License instead of this License.
--- /dev/null
+#
+# Copyright (C) 2026 Josef Schlehofer
+#
+# This is free software, licensed under the GNU General Public License v2.
+# See /LICENSE for more information.
+#
+
+include $(TOPDIR)/rules.mk
+
+PKG_NAME:=librespeed-common
+PKG_VERSION:=1.0.0
+PKG_RELEASE:=1
+
+PKG_MAINTAINER:=Josef Schlehofer <pepe.schlehofer@gmail.com>
+PKG_LICENSE:=GPL-2.0-only
+PKG_LICENSE_FILES:=LICENSE
+
+include $(INCLUDE_DIR)/package.mk
+
+define Package/librespeed-common
+ SECTION:=utils
+ CATEGORY:=Utilities
+ TITLE:=LibreSpeed measurement orchestration
+ DEPENDS:=+librespeed-cli +rpcd-mod-ucode +jsonfilter +ucode +ucode-mod-fs \
+ +ucode-mod-uci +jshn
+ PKGARCH:=all
+endef
+
+define Package/librespeed-common/description
+ Runs LibreSpeed measurements from UCI configuration: a single entry point
+ used both interactively and from cron, a schedule kept in step with UCI by
+ the init script, and results recorded as JSON for anything that wants to
+ read them, such as luci-app-librespeed, and a ubus interface for anything else.
+endef
+
+define Package/librespeed-common/conffiles
+/etc/config/librespeed
+endef
+
+# rpcd only enumerates its plugin directory at startup, so the librespeed ubus
+# object appears (and disappears) with a reload, not with the file.
+define Package/librespeed-common/postinst
+#!/bin/sh
+[ -n "$${IPKG_INSTROOT}" ] || /etc/init.d/rpcd reload
+exit 0
+endef
+
+define Package/librespeed-common/postrm
+#!/bin/sh
+[ -n "$${IPKG_INSTROOT}" ] || /etc/init.d/rpcd reload
+exit 0
+endef
+
+# No source archive to unpack: the license text ships beside the Makefile and
+# is staged so PKG_LICENSE_FILES points at a real file.
+define Build/Prepare
+ $(INSTALL_DATA) ./LICENSE $(PKG_BUILD_DIR)/
+endef
+
+define Build/Compile
+endef
+
+define Package/librespeed-common/install
+ $(INSTALL_DIR) $(1)/usr/libexec
+ $(INSTALL_BIN) ./files/librespeed-run $(1)/usr/libexec/librespeed-run
+ $(INSTALL_BIN) ./files/librespeed-aggregate $(1)/usr/libexec/librespeed-aggregate
+ $(SED) 's/%%VERSION%%/$(PKG_VERSION)-$(PKG_RELEASE)/g' \
+ $(1)/usr/libexec/librespeed-run \
+ $(1)/usr/libexec/librespeed-aggregate
+ $(INSTALL_DIR) $(1)/usr/share/rpcd/ucode
+ $(INSTALL_DATA) ./files/librespeed.uc $(1)/usr/share/rpcd/ucode/librespeed.uc
+ $(INSTALL_DIR) $(1)/etc/config
+ $(INSTALL_CONF) ./files/librespeed.config $(1)/etc/config/librespeed
+ $(INSTALL_DIR) $(1)/etc/init.d
+ $(INSTALL_BIN) ./files/librespeed.init $(1)/etc/init.d/librespeed
+endef
+
+$(eval $(call BuildPackage,librespeed-common))
--- /dev/null
+#!/usr/bin/env ucode
+// Reduces completed days of raw measurement history to one line per day --
+// min/avg/max over that day's samples, not a measurement itself -- and
+// appends them to the archive on persistent storage. Runs from cron shortly
+// after midnight; librespeed.init keeps that entry in step with UCI.
+//
+// Only days strictly before today are archived: a day's aggregate is written
+// once and never revisited, which is what makes reruns idempotent without any
+// marker file -- a day already present in the archive is simply skipped.
+// Today's raw measurements stay in RAM only; if power is lost they are gone,
+// which the Settings page says out loud.
+
+'use strict';
+
+import { open, readfile, rename, mkdir, unlink, error } from 'fs';
+
+// Packaging checks probe every executable for these.
+if (length(ARGV) > 0) {
+ if (ARGV[0] == '--version') {
+ print("librespeed-common %%VERSION%%\n");
+ exit(0);
+ }
+ print("Usage: librespeed-aggregate\n" +
+ "Reduces completed days of measurement history to daily min/avg/max\n" +
+ "aggregates. Runs from cron; takes no arguments.\n");
+ exit(0);
+}
+import { cursor } from 'uci';
+
+const METRICS = [ 'download_mbps', 'upload_mbps', 'ping_ms', 'jitter_ms' ];
+
+const uci = cursor();
+
+function conf(section, option, fallback) {
+ const v = uci.get('librespeed', section, option);
+
+ return (v == null || v == '') ? fallback : v;
+}
+
+if (conf('history', 'enabled', '1') == '0')
+ exit(0);
+
+const raw_path = conf('history', 'path', '/tmp/librespeed/history.jsonl');
+const archive_path = conf('history', 'archive_path', '');
+const archive_days = int(conf('history', 'archive_retention', '365d')) || 365;
+
+if (archive_path == '')
+ exit(0);
+
+function read_lines(path) {
+ const out = [];
+ const f = open(path, 'r');
+
+ if (!f)
+ return out;
+
+ for (let line = f.read('line'); length(line); line = f.read('line')) {
+ try {
+ push(out, json(line));
+ }
+ catch (e) {
+ continue;
+ }
+ }
+
+ f.close();
+
+ return out;
+}
+
+function day_key(epoch) {
+ const lt = localtime(epoch);
+
+ return sprintf('%04d-%02d-%02d', lt.year, lt.mon, lt.mday);
+}
+
+function day_start(key) {
+ const p = split(key, '-');
+
+ return timelocal({
+ year: int(p[0]), mon: int(p[1]), mday: int(p[2]),
+ hour: 0, min: 0, sec: 0
+ });
+}
+
+function round2(v) {
+ return int(v * 100 + 0.5) / 100.0;
+}
+
+const today = day_key(time());
+
+// Which days the archive already holds. Entries carry the day in `timestamp`.
+const archive = read_lines(archive_path);
+const have = {};
+
+for (let e in archive)
+ have[e.timestamp] = true;
+
+// Group raw lines by local calendar day, completed days only.
+const days = {};
+
+for (let e in read_lines(raw_path)) {
+ const epoch = int(e?.epoch ?? 0);
+
+ if (!epoch)
+ continue;
+
+ const key = day_key(epoch);
+
+ if (key >= today || have[key])
+ continue;
+
+ days[key] = days[key] ?? [];
+ push(days[key], e);
+}
+
+let changed = false;
+
+for (let key in sort(keys(days))) {
+ const entry = {
+ timestamp: key,
+ epoch: day_start(key),
+ samples: length(days[key])
+ };
+
+ for (let m in METRICS) {
+ let lo = null, hi = null, sum = 0.0, n = 0;
+
+ for (let e in days[key]) {
+ const v = e[m];
+
+ if (type(v) != 'double' && type(v) != 'int')
+ continue;
+
+ lo = (lo == null || v < lo) ? v : lo;
+ hi = (hi == null || v > hi) ? v : hi;
+ sum += v;
+ n++;
+ }
+
+ if (n > 0) {
+ // The mean lives in the plain field so a consumer that only knows
+ // raw entries keeps working; min and max sit beside it.
+ entry[m] = round2(sum / n);
+ entry[`${m}_min`] = lo;
+ entry[`${m}_max`] = hi;
+ }
+ }
+
+ push(archive, entry);
+ changed = true;
+}
+
+// Archive retention: integer comparison on the day-start epoch.
+const cutoff = time() - archive_days * 86400;
+const kept = filter(archive, e => int(e?.epoch ?? 0) >= cutoff);
+
+if (length(kept) != length(archive))
+ changed = true;
+
+if (!changed)
+ exit(0);
+
+let tmp = `${archive_path}.tmp`;
+let out = '';
+
+for (let e in sort(kept, (a, b) => int(a.epoch) - int(b.epoch)))
+ out += sprintf('%J\n', e);
+
+// The last component only, never the whole tree: archive_path commonly
+// points at external storage, and with the mount down a recursive mkdir
+// would build the path on the overlay and write every night's aggregate to
+// internal flash, to be shadowed once the disk is back. Failing here leaves
+// the location as the user prepared it.
+const dir = replace(archive_path, /\/[^\/]+$/, '');
+if (dir != '' && dir != archive_path)
+ mkdir(dir, 0o755);
+
+// Atomic: a reader never sees a half-written archive. Written by hand
+// rather than writefile(), which drops fclose()'s status and would let a
+// full disk truncate the archive in silence -- flush() is where a short
+// write surfaces. A failed write goes to syslog: this runs from cron,
+// where stderr has nowhere to go. The reason comes along, since a missing
+// mount, a read-only filesystem and a full disk each want something
+// different from whoever reads that log.
+const af = open(tmp, 'w');
+let wrote = af != null && af.write(out) == length(out);
+
+if (af) {
+ wrote = af.flush() != null && wrote;
+ af.close();
+}
+
+if (wrote)
+ rename(tmp, archive_path);
+else {
+ system(['logger', '-t', 'librespeed',
+ `aggregate: cannot write ${tmp}: ${error()}`]);
+ unlink(tmp);
+}
--- /dev/null
+#!/bin/sh
+#
+# Runs one LibreSpeed measurement and records the result.
+#
+# The only thing that starts a measurement: LuCI reaches it through rpcd, the
+# scheduler calls it directly. Holding the lock for the whole run means a second
+# invocation from either side fails instead of measuring against the first.
+
+# Packaging checks probe every executable for these; a runner that ignored
+# them would start a measurement instead of answering.
+case "$1" in
+--version)
+ echo "librespeed-common %%VERSION%%"
+ exit 0
+ ;;
+--help|-h)
+ cat <<'EOF'
+Usage: librespeed-run
+Runs one LibreSpeed measurement according to /etc/config/librespeed and
+records the result for the ubus interface. Started by LuCI or cron; takes
+no arguments.
+EOF
+ exit 0
+ ;;
+esac
+
+. /lib/functions.sh
+. /lib/functions/network.sh
+. /usr/share/libubox/jshn.sh
+
+CLI=/usr/bin/librespeed-cli
+STATE_DIR=/tmp/librespeed
+STATE="$STATE_DIR/state.json"
+RESULT="$STATE_DIR/result.json"
+LOCK=/var/lock/librespeed.lock
+
+mkdir -p "$STATE_DIR"
+
+# Writes $2 to $1 without ever leaving a half-written file for a reader.
+atomic_write() {
+ local target="$1" tmp="$1.$$"
+ printf '%s\n' "$2" > "$tmp" && mv "$tmp" "$target"
+}
+
+write_state() {
+ json_init
+ json_add_boolean running "$1"
+ [ -n "$2" ] && json_add_int pid "$2"
+ [ -n "$3" ] && json_add_int started "$3"
+ [ -n "$4" ] && json_add_int last_finished "$4"
+ json_add_string last_error "${5:-}"
+ json_add_string phase "${6:-}"
+ [ -n "$7" ] && json_add_double mbps "$7"
+ [ -n "$8" ] && json_add_int progress "$8"
+ atomic_write "$STATE" "$(json_dump)"
+}
+
+# Approximates progress from the interface byte counters, one sample a second.
+#
+# The fallback narrator for clients that cannot stream: it reads the whole
+# interface, not the test, so it is honest only while the test dominates the
+# link -- which is exactly the situation a progress line describes. Whichever
+# direction carries the traffic names the phase.
+sampler() {
+ local dev="$1" rx tx prx ptx phase mbps
+ [ -r "/sys/class/net/$dev/statistics/rx_bytes" ] || return 0
+ prx=$(cat "/sys/class/net/$dev/statistics/rx_bytes")
+ ptx=$(cat "/sys/class/net/$dev/statistics/tx_bytes")
+ while :; do
+ sleep 1
+ rx=$(cat "/sys/class/net/$dev/statistics/rx_bytes" 2>/dev/null) || return 0
+ tx=$(cat "/sys/class/net/$dev/statistics/tx_bytes" 2>/dev/null) || return 0
+ set -- $(awk -v rx="$rx" -v prx="$prx" -v tx="$tx" -v ptx="$ptx" 'BEGIN {
+ drx = (rx - prx) * 8 / 1000000
+ dtx = (tx - ptx) * 8 / 1000000
+ if (drx >= dtx && drx > 1) printf "download %.1f", drx
+ else if (dtx > 1) printf "upload %.1f", dtx
+ else printf "- -"
+ }')
+ prx=$rx; ptx=$tx
+ # The runner may die without reaching its kill (SIGKILL, a trap that
+ # never ran); without this check the sampler would rewrite state.json
+ # forever and mask every later run's result.
+ kill -0 $$ 2>/dev/null || return 0
+ # An idle second keeps the last phase: the frontend reads an empty
+ # phase as "not started yet" and would collapse mid-run.
+ if [ "$1" = "-" ]; then
+ write_state 1 $$ "$started" "" "" "$phase" ""
+ else
+ phase=$1
+ write_state 1 $$ "$started" "" "" "$1" "$2"
+ fi
+ done
+}
+
+fail() {
+ # A run that failed with the cached server drops the cache: the next run
+ # rediscovers instead of failing against the same dead choice forever.
+ [ "${used_cache:-0}" = 1 ] && rm -f "$LIST_CACHE" "$LIST_CACHE.src" "$CHOICE_CACHE"
+ write_state 0 "" "" "$(date +%s)" "$1"
+ logger -t librespeed "measurement failed: $1"
+ exit 1
+}
+
+# One measurement at a time. The descriptor stays open for the whole run, so the
+# kernel releases the lock even if this script is killed -- there is no stale
+# state to time out.
+exec 9>"$LOCK"
+flock -n 9 || {
+ echo "already running" >&2
+ exit 3
+}
+
+config_load librespeed
+config_get iface main interface wan
+config_get server main server auto
+config_get scheme main scheme auto
+config_get server_list main server_list ''
+config_get_bool hist_enabled history enabled 1
+config_get hist_path history path "$STATE_DIR/history.jsonl"
+config_get hist_retention history retention 30d
+
+# --interface takes a device, and UCI carries a logical interface name.
+dev=""
+network_get_device dev "$iface" 2>/dev/null
+
+[ -x "$CLI" ] || fail "librespeed-cli is not installed"
+
+# The Rust client reports progress as NDJSON under --json-stream; the Go one
+# does not have the flag yet. Asking --help is one extra exec per measurement
+# and keeps one script driving either client.
+stream=0
+if "$CLI" --help 2>&1 | grep -q -- '--json-stream'; then
+ stream=1
+ set -- "$CLI" --json-stream
+else
+ set -- "$CLI" --json
+fi
+# The router-side counterpart of the web UI remembering its chosen server:
+# after an automatic run the picked server's id and the downloaded list are
+# cached, and later runs go straight to the same server with --server and
+# --local-json instead of fetching the list and pinging everything on it.
+LIST_CACHE="$STATE_DIR/servers.json"
+CHOICE_CACHE="$STATE_DIR/server-choice"
+CACHE_TTL=86400
+# Where the list comes from: a self-hosted deployment -- Turris runs
+# https://librespeed.turris.cz/servers.json -- replaces the official one.
+LIST_URL="${server_list:-https://librespeed.org/backend-servers/servers.php}"
+
+cache_fresh=0
+c_id=""
+if [ -s "$LIST_CACHE" ] && [ -s "$CHOICE_CACHE" ]; then
+ read -r c_epoch c_id < "$CHOICE_CACHE"
+ # Both halves are checked before the arithmetic: a non-numeric value there
+ # is a fatal error in ash, and the run would die before writing any state.
+ case "$c_id" in ""|*[!0-9]*) c_id="" ;; esac
+ case "$c_epoch" in ""|*[!0-9]*) c_id="" ;; esac
+ [ -n "$c_id" ] && [ $(( $(date +%s) - c_epoch )) -lt "$CACHE_TTL" ] || c_id=""
+ # A cache fetched from another list is no cache at all.
+ [ "$(cat "$LIST_CACHE.src" 2>/dev/null)" = "$LIST_URL" ] || c_id=""
+fi
+[ -n "$c_id" ] && cache_fresh=1
+
+used_cache=0
+if [ "$server" != "auto" ]; then
+ set -- "$@" --server "$server"
+ # An explicit id still needs the list to resolve it; the cached copy
+ # saves that download too.
+ if [ "$cache_fresh" = 1 ]; then
+ set -- "$@" --local-json "$LIST_CACHE"
+ used_cache=1
+ elif [ -n "$server_list" ]; then
+ set -- "$@" --server-json "$LIST_URL"
+ fi
+elif [ "$cache_fresh" = 1 ]; then
+ set -- "$@" --server "$c_id" --local-json "$LIST_CACHE"
+ used_cache=1
+elif [ -n "$server_list" ]; then
+ set -- "$@" --server-json "$LIST_URL"
+fi
+[ -n "$dev" ] && set -- "$@" --interface "$dev"
+# TLS itself can bound the result on routers without AES acceleration, so the
+# scheme is a measurement setting, not just a transport detail.
+case "$scheme" in
+https) set -- "$@" --secure ;;
+http) set -- "$@" --insecure ;;
+esac
+# Never --bytes: it switches the report to MB/s and the history would end up
+# holding two units that cannot be told apart afterwards.
+
+started=$(date +%s)
+write_state 1 $$ "$started"
+
+# stop kills our whole process group, so librespeed-cli dies with us; ash runs
+# this trap once the foreground child has exited, and it records that the run
+# was stopped rather than pretending the measurement failed.
+trap '[ -n "$sampler_pid" ] && kill "$sampler_pid" 2>/dev/null
+ write_state 0 "" "" "$(date +%s)" "stopped"; exit 1' TERM
+sampler_pid=
+
+if [ "$stream" = 1 ]; then
+ # The while runs in a subshell, so the reports cannot come back in a
+ # variable; they land in a file instead. State updates are throttled to
+ # nothing -- one arrives a second and state.json lives in tmpfs.
+ rm -f "$STATE_DIR/reports.json"
+ "$@" 2>"$STATE_DIR/stderr.log" < /dev/null | while IFS= read -r line; do
+ case "$line" in
+ *'"event":"result"'*)
+ printf '%s' "$line" | jsonfilter -e '@.reports' \
+ > "$STATE_DIR/reports.json" 2>/dev/null
+ ;;
+ *'"event":"progress"'*)
+ write_state 1 $$ "$started" "" "" \
+ "$(printf '%s' "$line" | jsonfilter -e '@.phase' 2>/dev/null)" \
+ "$(printf '%s' "$line" | jsonfilter -e '@.mbps' 2>/dev/null)" \
+ "$(printf '%s' "$line" | jsonfilter -e '@.progress' 2>/dev/null)"
+ ;;
+ *'"event":"phase"'*)
+ write_state 1 $$ "$started" "" "" \
+ "$(printf '%s' "$line" | jsonfilter -e '@.phase' 2>/dev/null)" ""
+ ;;
+ esac
+ done
+ # The pipeline's status is the reader's, so success is judged by what the
+ # stream delivered: a client that failed never emitted a result event.
+ out=$(cat "$STATE_DIR/reports.json" 2>/dev/null)
+ rm -f "$STATE_DIR/reports.json"
+ if [ -z "$out" ]; then
+ # tail succeeds on an empty file, so the substitution guards the
+ # value, not the command: an empty log still reports a failure.
+ err=$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null)
+ fail "${err:-measurement failed}"
+ fi
+else
+ if [ -n "$dev" ]; then
+ sampler "$dev" &
+ sampler_pid=$!
+ fi
+ out=$("$@" 2>"$STATE_DIR/stderr.log" < /dev/null); rc=$?
+ [ -n "$sampler_pid" ] && kill "$sampler_pid" 2>/dev/null
+ if [ "$rc" != 0 ]; then
+ err=$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null)
+ fail "${err:-measurement failed (exit $rc)}"
+ fi
+fi
+
+finished=$(date +%s)
+
+[ -n "$out" ] || fail "no output from librespeed-cli"
+# Both clients print an array of reports, one per server tested: the Go
+# client marshals []report.JSONReport, and the Rust port mirrors that shape.
+# jshn cannot load a bare array -- blobmsg wants an object at the top -- so the
+# report array is wrapped before parsing. Found the hard way on a router: this
+# is exactly the step no macOS test could reach.
+json_load "{ \"reports\": $out }" 2>/dev/null || fail "unparseable output from librespeed-cli"
+json_select reports 2>/dev/null || fail "unparseable output from librespeed-cli"
+
+# The report is an array with one entry per server tested.
+json_select 1 2>/dev/null || fail "empty report"
+
+json_get_var ts timestamp
+json_get_var ping ping
+json_get_var jitter jitter
+json_get_var download download
+json_get_var upload upload
+json_get_var bsent bytes_sent
+json_get_var brecv bytes_received
+json_get_var share share
+
+srv_id=""; srv_name=""; srv_url=""
+if json_select server 2>/dev/null; then
+ json_get_var srv_id id
+ json_get_var srv_name name
+ json_get_var srv_url url
+ json_select ..
+fi
+
+cli_ip=""; cli_org=""
+if json_select client 2>/dev/null; then
+ json_get_var cli_ip ip
+ json_get_var cli_org org
+ json_select ..
+fi
+
+# The family of the address the backend saw is the family the test ran over.
+# Derived here rather than asked of the CLI, so it works with any client; a
+# redacted or missing address simply leaves the field out.
+family=""
+case "$cli_ip" in
+*:*) family="ipv6" ;;
+*.*) family="ipv4" ;;
+esac
+
+# Whether the run was encrypted is visible from the URL the CLI settled on;
+# stored per measurement because the scheme option can change between runs.
+proto=""
+case "$srv_url" in
+https:*) proto="https" ;;
+http:*) proto="http" ;;
+esac
+
+# result.json -- the last completed measurement, not a database.
+json_init
+json_add_string timestamp "$ts"
+json_add_int started "$started"
+json_add_int finished "$finished"
+json_add_string interface "$iface"
+json_add_object server
+ # Absent until the CLI reports it; consumers treat null as unknown.
+ [ -n "$srv_id" ] && json_add_int id "$srv_id"
+ json_add_string name "$srv_name"
+ json_add_string url "$srv_url"
+json_close_object
+json_add_object client
+ json_add_string ip "$cli_ip"
+ json_add_string org "$cli_org"
+json_close_object
+[ -n "$family" ] && json_add_string family "$family"
+[ -n "$proto" ] && json_add_string proto "$proto"
+json_add_double download_mbps "$download"
+json_add_double upload_mbps "$upload"
+json_add_double ping_ms "$ping"
+json_add_double jitter_ms "$jitter"
+json_add_int bytes_sent "$bsent"
+json_add_int bytes_received "$brecv"
+json_add_string share "$share"
+result="$(json_dump)"
+atomic_write "$RESULT" "$result"
+
+if [ "$hist_enabled" = "1" ]; then
+ mkdir -p "$(dirname "$hist_path")"
+
+ json_init
+ json_add_string timestamp "$ts"
+ json_add_int epoch "$finished"
+ json_add_string interface "$iface"
+ json_add_object server
+ [ -n "$srv_id" ] && json_add_int id "$srv_id"
+ json_add_string name "$srv_name"
+ json_add_string url "$srv_url"
+ json_close_object
+ [ -n "$family" ] && json_add_string family "$family"
+ [ -n "$proto" ] && json_add_string proto "$proto"
+ json_add_double download_mbps "$download"
+ json_add_double upload_mbps "$upload"
+ json_add_double ping_ms "$ping"
+ json_add_double jitter_ms "$jitter"
+ printf '%s\n' "$(json_dump)" >> "$hist_path"
+
+ # Retention. The epoch above makes this an integer comparison, so no date(1)
+ # runs here -- a year of history is thousands of lines and forking once per
+ # line is not something a router should be asked to do.
+ days=${hist_retention%d}
+ case "$days" in
+ ''|*[!0-9]*) days=0 ;;
+ esac
+ if [ "$days" -gt 0 ]; then
+ cutoff=$(( finished - days * 86400 ))
+
+ # Compacting rewrites the whole file, so it happens in batches rather
+ # than whenever a single line falls out. Past the retention window every
+ # run expires something, and rewriting on each of them would push about
+ # a gigabyte a year through the flash instead of a few megabytes. The
+ # file therefore holds somewhat more than the window, and is trimmed
+ # once enough has accumulated to be worth the write.
+ # jshn writes '"epoch": 175...' with a space, so the match is
+ # tolerant and the digits are extracted rather than offset-counted.
+ expired=$(awk -v c="$cutoff" '
+ match($0, /"epoch":[[:space:]]*[0-9]+/) {
+ v = substr($0, RSTART, RLENGTH); gsub(/[^0-9]/, "", v)
+ if (v + 0 < c) n++
+ }
+ END { print n + 0 }
+ ' "$hist_path")
+ total=$(wc -l < "$hist_path")
+
+ if [ "$expired" -ge 50 ] || [ "$expired" -ge $(( total / 2 )) ] && [ "$expired" -gt 0 ]; then
+ tmp="$hist_path.$$"
+ awk -v c="$cutoff" '
+ match($0, /"epoch":[[:space:]]*[0-9]+/) {
+ v = substr($0, RSTART, RLENGTH); gsub(/[^0-9]/, "", v)
+ if (v + 0 < c) next
+ }
+ { print }
+ ' "$hist_path" > "$tmp" && mv "$tmp" "$hist_path"
+ fi
+ fi
+fi
+
+write_state 0 "" "" "$finished" ""
+
+# Refresh the server cache after the run, so the next one starts instantly:
+# at most one list download a day, and the choice is the server this run
+# actually used, looked up by the name the report carries.
+if [ "$cache_fresh" = 0 ]; then
+ if uclient-fetch -q -T 15 -O "$LIST_CACHE.tmp" "$LIST_URL" 2>/dev/null \
+ && [ -s "$LIST_CACHE.tmp" ]; then
+ mv "$LIST_CACHE.tmp" "$LIST_CACHE"
+ printf '%s\n' "$LIST_URL" > "$LIST_CACHE.src"
+ else
+ rm -f "$LIST_CACHE.tmp"
+ fi
+ if [ -s "$LIST_CACHE" ] && [ -n "$srv_name" ]; then
+ new_id=$(LIST="$LIST_CACHE" NAME="$srv_name" ucode -e '
+ let fs = require("fs");
+ let list = json(fs.readfile(getenv("LIST")) || "[]");
+ for (s in list)
+ if (s.name == getenv("NAME")) { print(s.id); break; }
+ ' 2>/dev/null)
+ case "$new_id" in
+ ''|*[!0-9]*) ;;
+ *) atomic_write "$CHOICE_CACHE" "$(date +%s) $new_id" ;;
+ esac
+ fi
+fi
+logger -t librespeed "measurement done: ${download} Mbps down, ${upload} Mbps up"
+exit 0
--- /dev/null
+config librespeed 'main'
+ option interface 'wan'
+ option server 'auto'
+ option scheme 'auto'
+ option server_list ''
+
+config librespeed 'schedule'
+ option enabled '0'
+ option interval '1d'
+ option days '*'
+ option hours '2-5'
+
+config librespeed 'history'
+ option enabled '1'
+ option path '/tmp/librespeed/history.jsonl'
+ option retention '30d'
+ option archive_path ''
+ option archive_retention '365d'
--- /dev/null
+#!/bin/sh /etc/rc.common
+# Keeps the scheduled-measurement cron entry in step with UCI. There is no
+# daemon here: measurements are one-shot runs of librespeed-run, and the rpcd
+# interface needs no service of its own.
+
+START=95
+USE_PROCD=1
+
+CRONTAB=/etc/crontabs/root
+RUN=/usr/libexec/librespeed-run
+AGG=/usr/libexec/librespeed-aggregate
+
+# Builds the cron time fields from UCI. For sub-daily intervals `hours`
+# restricts when measurements may run; for a daily one it is the window a
+# random time is drawn from, drawn here at sync time. That keeps the herd
+# apart -- every router lands on its own minute -- without any process
+# sleeping through the night, at the cost of re-drawing on every reload.
+# Every comma-separated part must be a number or an ascending range within
+# [0, max]; the caller already reduced the alphabet to digits, commas and
+# dashes.
+valid_field() {
+ local val="$1" max="$2" part a b oifs="$IFS"
+ IFS=','
+ for part in $val; do
+ IFS="$oifs"
+ a=${part%-*}; b=${part#*-}
+ # Each half on its own: concatenated, '5-' and '-5' hide their empty
+ # side and the arithmetic below would print a shell error where the
+ # caller promises a silent fallback.
+ case "$a" in ''|*[!0-9]*) return 1 ;; esac
+ case "$b" in ''|*[!0-9]*) return 1 ;; esac
+ [ "$a" -le "$max" ] && [ "$b" -le "$max" ] && [ "$a" -le "$b" ] || return 1
+ done
+ IFS="$oifs"
+ return 0
+}
+
+cron_expr() {
+ local interval="$1" days="$2" hours="$3"
+ local start end m h v
+
+ case "$interval" in
+ *m)
+ v=${interval%m}
+ case "$v" in ''|0|*[!0-9]*) return 1 ;; esac
+ [ "$v" -le 59 ] || return 1
+ echo "*/$v ${hours:-*} * * $days"
+ ;;
+ *h)
+ v=${interval%h}
+ case "$v" in ''|0|*[!0-9]*) return 1 ;; esac
+ [ "$v" -le 23 ] || return 1
+ if [ -n "$hours" ]; then
+ echo "0 $hours/$v * * $days"
+ else
+ echo "0 */$v * * $days"
+ fi
+ ;;
+ 1d|24h)
+ start=${hours%-*}; end=${hours#*-}
+ [ -n "$hours" ] || { start=0; end=23; }
+ # A window wrapping midnight (22-4) is not supported; treat it as the
+ # single starting hour rather than guessing.
+ [ "$end" -ge "$start" ] 2>/dev/null || end=$start
+ # Time xor pid as the seed: seconds-granular srand() alone would give
+ # two syncs in the same second the same draw.
+ set -- $(awk -v a="$start" -v b="$end" -v s="$(( $(date +%s) ^ $$ ))" 'BEGIN {
+ srand(s)
+ printf "%d %d", int(rand() * 60), a + int(rand() * (b - a + 1))
+ }')
+ echo "$1 $2 * * $days"
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+restart_cron() {
+ # Unconditional: on a stock system the crontab dir starts empty, so crond
+ # is not running at all -- restart starts a stopped procd service, and a
+ # guard on "running" would mean the schedule never fires until reboot.
+ /etc/init.d/cron restart >/dev/null 2>&1
+ return 0
+}
+
+sync_cron() {
+ local enabled interval days hours expr hist_enabled archive_path
+
+ config_load librespeed
+ config_get_bool enabled schedule enabled 0
+ config_get interval schedule interval 1d
+ config_get days schedule days '*'
+ config_get hours schedule hours ''
+ config_get_bool hist_enabled history enabled 1
+ config_get archive_path history archive_path ''
+
+ # These end up verbatim in root's crontab, so anything unexpected becomes
+ # the harmless default rather than a cron field it was not meant to be.
+ # Values are checked as well as characters: '99' is made of digits, but
+ # as an hour it would turn a daily schedule into an hourly one.
+ case "$days" in *[!0-9,-]*|'') days='*' ;; esac
+ if [ "$days" != '*' ] && ! valid_field "$days" 6; then
+ days='*'
+ fi
+ case "$hours" in *[!0-9-]*) hours='' ;; esac
+ if [ -n "$hours" ] && ! valid_field "$hours" 23; then
+ hours=''
+ fi
+
+ mkdir -p "${CRONTAB%/*}"
+ touch "$CRONTAB"
+ sed -i "\\#$RUN#d;\\#$AGG#d" "$CRONTAB"
+
+ # The day completes at midnight; five past, it is reduced to one archive
+ # line. A missed run costs nothing to catch up: raw lives in RAM, so after
+ # a power cut there is nothing left to aggregate anyway.
+ if [ "$hist_enabled" = 1 ] && [ -n "$archive_path" ]; then
+ echo "5 0 * * * $AGG" >> "$CRONTAB"
+ fi
+
+ if [ "$enabled" = 1 ]; then
+ if expr=$(cron_expr "$interval" "$days" "$hours"); then
+ echo "$expr $RUN" >> "$CRONTAB"
+ else
+ logger -t librespeed "unsupported schedule interval '$interval'"
+ fi
+ fi
+
+ restart_cron
+}
+
+start_service() {
+ sync_cron
+}
+
+stop_service() {
+ sed -i "\\#$RUN#d;\\#$AGG#d" "$CRONTAB"
+ restart_cron
+}
+
+service_triggers() {
+ procd_add_reload_trigger "librespeed"
+}
+
+reload_service() {
+ sync_cron
+}
--- /dev/null
+#!/usr/bin/env ucode
+// ubus interface for LibreSpeed measurements.
+//
+// Runs inside rpcd, which is exactly why it never measures anything itself: a
+// measurement takes tens of seconds and would stall rpcd's event loop -- and
+// with it every rpcd consumer on the system. Anything long-lived is handed to
+// librespeed-run as a detached process.
+
+'use strict';
+
+import { open, readfile, stat } from 'fs';
+import { cursor } from 'uci';
+
+const STATE_DIR = '/tmp/librespeed';
+const STATE = `${STATE_DIR}/state.json`;
+const RESULT = `${STATE_DIR}/result.json`;
+const LOCK = '/var/lock/librespeed.lock';
+const RUN = '/usr/libexec/librespeed-run';
+
+function read_json(path) {
+ const text = readfile(path);
+
+ if (text == null)
+ return null;
+
+ let value = null;
+
+ try {
+ value = json(text);
+ }
+ catch (e) {
+ value = null;
+ }
+
+ return value;
+}
+
+// The process group of a pid, read from /proc: the runner is spawned detached
+// (start-stop-daemon -b or setsid), so its group holds the whole measurement
+// tree and nothing else. Parsed after the comm field's closing parenthesis,
+// the one place a process can put spaces.
+function pgid_of(pid) {
+ const st = readfile(`/proc/${pid}/stat`);
+
+ if (!st)
+ return 0;
+
+ const f = split(trim(substr(st, rindex(st, ')') + 1)), ' ');
+
+ return int(f[2] ?? 0);
+}
+
+// The lock says whether a measurement runs, not a field in a file: a process
+// that dies takes its lock with it, so there is no stale state to age out.
+function is_running() {
+ const f = open(LOCK, 'r');
+
+ if (!f)
+ return false;
+
+ const acquired = f.lock('xn');
+
+ if (acquired)
+ f.lock('u');
+
+ f.close();
+
+ return !acquired;
+}
+
+function config_get(uci, section, option, fallback) {
+ const v = uci.get('librespeed', section, option);
+
+ return (v == null || v == '') ? fallback : v;
+}
+
+// Next occurrences of the drawn cron line, computed here rather than in the
+// browser: the schedule fires in the router's timezone, and the browser may
+// well sit in another one. Understands only the shapes librespeed.init
+// emits: numbers, ranges, ranges with a step, star, and comma lists.
+// Hoisted: ucode recompiles a regex literal on every evaluation, and these
+// two dominate cron_next's cost inside rpcd, which must never stall.
+const CRON_STEP = /^(.+)\/([0-9]+)$/;
+const CRON_RANGE = /^([0-9]+)-([0-9]+)$/;
+
+function cron_next(line, count) {
+ const f = split(trim(line ?? ''), /\s+/);
+
+ if (length(f) < 5)
+ return [];
+
+ const match_field = function(pat, val) {
+ for (let part in split(pat, ',')) {
+ let step = 1;
+ let m = match(part, CRON_STEP);
+
+ if (m) {
+ part = m[1];
+ step = int(m[2]);
+ }
+
+ let a, b;
+
+ if (part == '*') {
+ a = 0;
+ b = 59;
+ }
+ else {
+ m = match(part, CRON_RANGE);
+ if (m) {
+ a = int(m[1]);
+ b = int(m[2]);
+ }
+ else {
+ a = int(part);
+ b = a;
+ }
+ }
+
+ if (val >= a && val <= b && (val - a) % step == 0)
+ return true;
+ }
+
+ return false;
+ };
+
+ const out = [];
+ let t = time();
+ t -= t % 60;
+
+ // A miss skips the rest of the day or hour instead of walking its
+ // minutes; that keeps this cheap inside rpcd's event loop, and cheap
+ // enough for a five-week horizon, which a weekly schedule needs to
+ // fill three rows where eight days could not. The day jump aims at
+ // 23:00, not midnight: it is computed in local minutes but applied as
+ // real ones, and across a spring-forward that lands an hour long --
+ // from 23:00 the hour branch walks the last hour and cannot overshoot
+ // into minutes that were never examined.
+ for (let i = 0; i < 35 * 24 * 60 && length(out) < count; ) {
+ t += 60;
+ const lt = localtime(t);
+ let skip;
+
+ // % 7 folds both weekday conventions onto cron's 0-6 with Sunday 0.
+ if (!match_field(f[4], lt.wday % 7)) {
+ skip = (24 - lt.hour) * 60 - lt.min - 60;
+ if (skip <= 0)
+ skip = 60 - lt.min;
+ }
+ else if (!match_field(f[1], lt.hour))
+ skip = 60 - lt.min;
+ else {
+ if (match_field(f[0], lt.min))
+ push(out, t);
+ i++;
+ continue;
+ }
+
+ t += (skip - 1) * 60;
+ i += skip;
+ }
+
+ return out;
+}
+
+const methods = {
+ start: {
+ call: function() {
+ if (is_running())
+ return { error: 'already running' };
+
+ if (!stat(RUN))
+ return { error: 'not installed' };
+
+ // Detached: the frontend polls status instead of waiting here.
+ system(`start-stop-daemon -S -b -x ${RUN} >/dev/null 2>&1 || ( setsid ${RUN} >/dev/null 2>&1 & )`);
+
+ return { started: true };
+ }
+ },
+
+ stop: {
+ call: function() {
+ const st = read_json(STATE);
+
+ // Kill the whole process group, not just the wrapper shell:
+ // librespeed-cli and the sampler must die with it, or a "stopped"
+ // answer would leave the measurement running on inherited fds.
+ // busybox kill takes the negative pgid without `--`.
+ if (is_running() && st?.pid) {
+ const pg = pgid_of(int(st.pid));
+
+ if (pg > 0)
+ system(`kill -TERM -${pg} 2>/dev/null`);
+ else
+ system(`kill -TERM ${int(st.pid)} 2>/dev/null`);
+ }
+
+ return { stopped: true };
+ }
+ },
+
+ status: {
+ call: function() {
+ const st = read_json(STATE) ?? {};
+
+ if (is_running()) {
+ const out = { running: true, phase: st.phase ?? '' };
+
+ if (st.pid)
+ out.pid = int(st.pid);
+ if (st.started) {
+ out.started = int(st.started);
+ // Elapsed is computed here, on the clock that stamped
+ // started: the browser's clock may sit anywhere.
+ out.elapsed = time() - int(st.started);
+ }
+ if (st.mbps != null)
+ out.mbps = st.mbps + 0.0;
+ if (st.progress != null)
+ out.progress = int(st.progress);
+
+ return out;
+ }
+
+ const out = { running: false, last_error: st.last_error ?? '' };
+
+ if (st.last_finished)
+ out.last_finished = int(st.last_finished);
+
+ return out;
+ }
+ },
+
+ result: {
+ call: function() {
+ return read_json(RESULT) ?? {};
+ }
+ },
+
+ // Contract: one response comes from exactly one source. A range that fits
+ // the raw retention window returns raw measurements; an older range is
+ // served from the daily archive at 1d resolution (completed days only, so
+ // today is absent there). The two never mix in one response --
+ // `resolution` names the source, and a consumer comparing two windows
+ // must compare like with like.
+ history: {
+ args: { from: 0, to: 0, limit: 0 },
+ call: function(request) {
+ const from = int(request.args?.from ?? 0);
+ const to = int(request.args?.to ?? 0);
+ const limit = int(request.args?.limit ?? 0);
+
+ const uci = cursor();
+ const raw_path = config_get(uci, 'history', 'path',
+ `${STATE_DIR}/history.jsonl`);
+ const archive_path = config_get(uci, 'history', 'archive_path', '');
+ const raw_days = int(config_get(uci, 'history', 'retention', '30d')) || 30;
+ uci.unload('librespeed');
+
+ // Ranges the raw window can answer come from raw; anything reaching
+ // further back is served from the daily archive when one is kept.
+ // The hour of slack keeps the boundary request -- "the last 30
+ // days" against a 30-day window -- from flapping between sources
+ // over clock skew.
+ let resolution = 'raw';
+ let path = raw_path;
+
+ if (archive_path != '' && stat(archive_path) &&
+ (from == 0 || from < time() - raw_days * 86400 - 3600)) {
+ resolution = '1d';
+ path = archive_path;
+ }
+
+ const entries = [];
+ const f = open(path, 'r');
+
+ if (f) {
+ for (let line = f.read('line'); length(line); line = f.read('line')) {
+ let e = null;
+
+ try {
+ e = json(line);
+ }
+ catch (err) {
+ continue;
+ }
+
+ if (from > 0 && int(e?.epoch ?? 0) < from)
+ continue;
+ if (to > 0 && int(e?.epoch ?? 0) > to)
+ continue;
+
+ push(entries, e);
+ }
+
+ f.close();
+ }
+
+ // Newest N, still oldest first.
+ const kept = (limit > 0 && length(entries) > limit)
+ ? slice(entries, -limit) : entries;
+
+ return { resolution: resolution, entries: kept };
+ }
+ },
+
+ config: {
+ call: function() {
+ const uci = cursor();
+
+ // The drawn schedule lives in the crontab, not in UCI: for a daily
+ // interval the time is picked at sync. Handing the line out lets
+ // the frontend show when measurements will actually run.
+ let cron = '';
+ const cf = open('/etc/crontabs/root', 'r');
+
+ if (cf) {
+ for (let line = cf.read('line'); length(line); line = cf.read('line'))
+ if (index(line, '/usr/libexec/librespeed-run') >= 0)
+ cron = trim(line);
+ cf.close();
+ }
+
+ const out = {
+ interface: config_get(uci, 'main', 'interface', 'wan'),
+ server: config_get(uci, 'main', 'server', 'auto'),
+ scheme: config_get(uci, 'main', 'scheme', 'auto'),
+ server_list: config_get(uci, 'main', 'server_list', ''),
+ schedule: {
+ // What the crontab holds, not what UCI intends: a
+ // hand-set 'true' satisfies the init script's bool but
+ // not a string compare, and the page would say No while
+ // cron fires. The line is the one source of truth.
+ enabled: cron != '',
+ interval: config_get(uci, 'schedule', 'interval', '1d'),
+ days: config_get(uci, 'schedule', 'days', '*'),
+ hours: config_get(uci, 'schedule', 'hours', ''),
+ cron: cron,
+ next_runs: cron != '' ? cron_next(cron, 3) : []
+ },
+ history: {
+ enabled: config_get(uci, 'history', 'enabled', '1') != '0',
+ path: config_get(uci, 'history', 'path',
+ `${STATE_DIR}/history.jsonl`),
+ retention: config_get(uci, 'history', 'retention', '30d')
+ }
+ };
+
+ uci.unload('librespeed');
+
+ return out;
+ }
+ }
+};
+
+return { librespeed: methods };
--- /dev/null
+#!/bin/sh
+# The package ships scripts and an rpcd plugin, no binary of its own, so the
+# generic --version probe cannot apply; check the installed pieces instead.
+
+fail() { echo "FAIL: $1"; exit 1; }
+
+[ -x /usr/libexec/librespeed-run ] || fail "librespeed-run not installed"
+[ -x /usr/libexec/librespeed-aggregate ] || fail "librespeed-aggregate not installed"
+[ -f /usr/share/rpcd/ucode/librespeed.uc ] || fail "rpcd plugin not installed"
+[ -f /etc/config/librespeed ] || fail "UCI config not installed"
+[ -x /etc/init.d/librespeed ] || fail "init script not installed"
+
+sh -n /usr/libexec/librespeed-run || fail "librespeed-run does not parse"
+/usr/libexec/librespeed-run --version | grep librespeed-common \
+ || fail "librespeed-run --version"
+/usr/libexec/librespeed-aggregate --version | grep librespeed-common \
+ || fail "librespeed-aggregate --version"
+sh -n /etc/init.d/librespeed || fail "init script does not parse"
+
+# The plugin file has no side effects at load time: it defines its methods
+# and returns them. Run as a file, the way rpcd loads it -- include() cannot
+# be used here, it rejects the module import statements the plugin needs.
+ucode /usr/share/rpcd/ucode/librespeed.uc >/dev/null \
+ || fail "rpcd plugin does not load"
+
+# Retention must recognise epoch as jshn actually writes it -- with a space
+# after the colon. The fixture comes from json_dump itself, so the check
+# breaks if either side changes shape.
+line=$(. /usr/share/libubox/jshn.sh; json_init; json_add_int epoch 1; json_dump) \
+ || fail "jshn not usable"
+echo "$line" | awk 'match($0, /"epoch":[[:space:]]*[0-9]+/) { ok = 1 }
+ { print }
+ END { exit !ok }' || fail "retention regex does not match jshn output"
+
+echo "librespeed-common: installed files OK"