Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
355 changes: 355 additions & 0 deletions utils/librespeed-common/LICENSE

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions utils/librespeed-common/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#
# 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
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
Comment thread
BKPepe marked this conversation as resolved.

# 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))
184 changes: 184 additions & 0 deletions utils/librespeed-common/files/librespeed-aggregate
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/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, writefile, rename, mkdir } 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. A failed write goes to
// syslog: this runs from cron, where stderr has nowhere to go.
if (writefile(tmp, out) != null)
rename(tmp, archive_path);
Comment on lines +181 to +182

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: archive_path is free-form UCI pointing at persistent storage the user chose, and nothing creates its directory — the runner only does mkdir -p for the raw history path. If the user sets e.g. /srv/librespeed/archive.jsonl before the mount point exists, writefile returns null, the branch is skipped and the cron job goes on doing nothing every night with no trace anywhere. A mkdir of dirname(archive_path) before the write, or at least a warn() on the failed write, would make that diagnosable.


Generated by Claude Code

else
system(['logger', '-t', 'librespeed', `aggregate: cannot write ${tmp}`]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (optional): the log line names the file but not why the write failed, and the three causes this hunk exists for want different actions from the admin — mount not up (ENOENT), wrong owner or a read-only filesystem (EACCES/EROFS), full disk (ENOSPC). All three produce the identical cannot write /mnt/.../archive.jsonl.tmp every night.

ucode's fs module keeps the reason in error(); luci.openvpn.uc:5 imports it alongside the rest of the module. Adding error to the import on line 15 and interpolating it here:

system(['logger', '-t', 'librespeed', `aggregate: cannot write ${tmp}: ${error()}`]);

No suggestion block since the import on line 15 has to change too. Nothing here blocks a merge.


Generated by Claude Code

Loading
Loading