aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-17 19:04:34 +0200
committerDanilo M. <danix@danix.xyz>2026-09-17 19:04:34 +0200
commitc521d9eb71c9ca96348598d20eecb5ffd746aabf (patch)
tree7ffcc2d0426bfaeb6d6944c9360466daed864876
parent1aa67dbe3e711617895d7b1d6e4dffccc74fa95a (diff)
downloadconky-theme-udt-c521d9eb71c9ca96348598d20eecb5ffd746aabf.tar.gz
conky-theme-udt-c521d9eb71c9ca96348598d20eecb5ffd746aabf.zip
feat: add a byte-rate counter for interface statistics
Mirrors new_cpu_counter: cumulative counters need two samples to yield a rate, and each direction carries its own previous reading. Clamps a negative delta to zero rather than letting a 32-bit wrap or an interface reset produce a spike. One bogus sample sets the shared autoscale and flattens the entire window. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--lib/data.lua37
-rw-r--r--test/test_data.lua31
2 files changed, 68 insertions, 0 deletions
diff --git a/lib/data.lua b/lib/data.lua
index 2047ef8..9f20d1f 100644
--- a/lib/data.lua
+++ b/lib/data.lua
@@ -113,6 +113,43 @@ function M.new_cpu_counter()
}
end
+-- A stateful byte-rate counter, for an interface's rx/tx totals.
+--
+-- Same shape as new_cpu_counter and for the same reason: the counters are
+-- cumulative, so one reading cannot produce a rate, and each direction needs
+-- its own previous sample.
+--
+-- `fallback_dt` is the draw interval, used when two samples land in the same
+-- clock second. os.time() resolves to whole seconds and the dashboard draws
+-- every two, so equal timestamps are ordinary, not exceptional.
+function M.new_rate_counter(fallback_dt)
+ return {
+ prev_bytes = nil,
+ prev_time = nil,
+ -- Returns bytes per second since the previous sample, or nil when there is
+ -- no usable delta (first call, or a nil reading from a vanished interface).
+ sample = function(self, bytes, now)
+ if type(bytes) ~= 'number' then return nil end
+ now = now or os.time()
+ local pb, pt = self.prev_bytes, self.prev_time
+ self.prev_bytes, self.prev_time = bytes, now
+ if not pb then return nil end
+
+ local dt = now - pt
+ -- Same second, or a clock that went backwards over an NTP step.
+ if dt <= 0 then dt = fallback_dt or 2 end
+
+ local db = bytes - pb
+ -- A negative delta is a 32-bit wrap or an interface reset. Zero, never
+ -- the huge positive the wrap arithmetic would imply: one bogus sample
+ -- sets the shared autoscale and flattens the whole window.
+ if db < 0 then db = 0 end
+
+ return db / dt
+ end,
+ }
+end
+
-- Per-core jiffies from /proc/stat, in file order, so entry N is core N.
--
-- Separate from cpu_times() rather than a flag on it: the aggregate is a
diff --git a/test/test_data.lua b/test/test_data.lua
index 4d3280c..478bf78 100644
--- a/test/test_data.lua
+++ b/test/test_data.lua
@@ -178,4 +178,35 @@ assert(cut == string.rep('\u{00E9}', 9) .. '\u{2026}',
assert(card.truncate(nil, 10) == '', 'nil truncates to empty')
assert(card.truncate('abc', 0) == '', 'a zero limit gives empty')
+-- === Network rate =========================================================
+-- Interface byte counters are cumulative, so a rate is a delta over elapsed
+-- time. The first call has no previous sample and must report nil: any number
+-- it invented would be wrong, and a spike at startup looks real.
+local r = data.new_rate_counter()
+assert(r:sample(1000, 100) == nil, 'first sample must give nil')
+
+-- 2048 bytes over 2 seconds is 1024 B/s.
+local rate = r:sample(3048, 102)
+assert(rate == 1024, 'second sample, got ' .. tostring(rate))
+
+-- A counter that went backwards means a wrap or an interface reset. It must
+-- clamp to zero, never produce the huge positive an unsigned wrap implies:
+-- one bogus sample poisons the shared autoscale for the whole window.
+local r2 = data.new_rate_counter()
+r2:sample(5000, 100)
+assert(r2:sample(10, 102) == 0, 'a counter reset must give 0')
+
+-- Two samples inside the same clock second. os.time() has whole-second
+-- resolution against a 2s draw interval, so this happens in normal operation
+-- and must not divide by zero.
+local r3 = data.new_rate_counter()
+r3:sample(1000, 500)
+local same_second = r3:sample(2000, 500)
+assert(same_second ~= nil and same_second >= 0,
+ 'a zero time delta must fall back to the draw interval, got ' .. tostring(same_second))
+
+-- A missing interface reads nil, which must propagate rather than raise.
+local r4 = data.new_rate_counter()
+assert(r4:sample(nil, 100) == nil, 'a nil byte count gives nil')
+
print('test_data: all assertions passed')