-
Notifications
You must be signed in to change notification settings - Fork 3.9k
librespeed-common: add measurement backend #30294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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 | ||
|
|
||
| # 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)) | ||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Generated by Claude Code |
||
| else | ||
| system(['logger', '-t', 'librespeed', `aggregate: cannot write ${tmp}`]); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( ucode's No suggestion block since the import on line 15 has to change too. Nothing here blocks a merge. Generated by Claude Code |
||
Uh oh!
There was an error while loading. Please reload this page.