]> git.99rst.org Git - openwrt-packages.git/commitdiff
librespeed-common: add measurement backend
authorJosef Schlehofer <redacted>
Sun, 16 Aug 2026 10:40:23 +0000 (12:40 +0200)
committerJosef Schlehofer <redacted>
Thu, 3 Sep 2026 12:53:08 +0000 (14:53 +0200)
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>
utils/librespeed-common/LICENSE [new file with mode: 0644]
utils/librespeed-common/Makefile [new file with mode: 0644]
utils/librespeed-common/files/librespeed-aggregate [new file with mode: 0755]
utils/librespeed-common/files/librespeed-run [new file with mode: 0755]
utils/librespeed-common/files/librespeed.config [new file with mode: 0644]
utils/librespeed-common/files/librespeed.init [new file with mode: 0644]
utils/librespeed-common/files/librespeed.uc [new file with mode: 0644]
utils/librespeed-common/test.sh [new file with mode: 0755]

diff --git a/utils/librespeed-common/LICENSE b/utils/librespeed-common/LICENSE
new file mode 100644 (file)
index 0000000..4d90dc1
--- /dev/null
@@ -0,0 +1,355 @@
+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.
diff --git a/utils/librespeed-common/Makefile b/utils/librespeed-common/Makefile
new file mode 100644 (file)
index 0000000..974582e
--- /dev/null
@@ -0,0 +1,78 @@
+#
+# 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))
diff --git a/utils/librespeed-common/files/librespeed-aggregate b/utils/librespeed-common/files/librespeed-aggregate
new file mode 100755 (executable)
index 0000000..2c3a078
--- /dev/null
@@ -0,0 +1,200 @@
+#!/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);
+}
diff --git a/utils/librespeed-common/files/librespeed-run b/utils/librespeed-common/files/librespeed-run
new file mode 100755 (executable)
index 0000000..61423b0
--- /dev/null
@@ -0,0 +1,417 @@
+#!/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
diff --git a/utils/librespeed-common/files/librespeed.config b/utils/librespeed-common/files/librespeed.config
new file mode 100644 (file)
index 0000000..3fa1afa
--- /dev/null
@@ -0,0 +1,18 @@
+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'
diff --git a/utils/librespeed-common/files/librespeed.init b/utils/librespeed-common/files/librespeed.init
new file mode 100644 (file)
index 0000000..a1a87d6
--- /dev/null
@@ -0,0 +1,148 @@
+#!/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
+}
diff --git a/utils/librespeed-common/files/librespeed.uc b/utils/librespeed-common/files/librespeed.uc
new file mode 100644 (file)
index 0000000..fee8b97
--- /dev/null
@@ -0,0 +1,357 @@
+#!/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 };
diff --git a/utils/librespeed-common/test.sh b/utils/librespeed-common/test.sh
new file mode 100755 (executable)
index 0000000..bc33849
--- /dev/null
@@ -0,0 +1,35 @@
+#!/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"
git clone https://git.99rst.org/PROJECT