aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-17 19:15:59 +0200
committerDanilo M. <danix@danix.xyz>2026-09-17 19:15:59 +0200
commit7b34edc8e7079a16755e342694173e9d194425d7 (patch)
tree57025a7f382a4170f6d34e01c16772da016b2541
parent8a6c0730f8e9f2c1e6033d3e66d2c524d7b9f91d (diff)
downloadconky-theme-udt-7b34edc8e7079a16755e342694173e9d194425d7.tar.gz
conky-theme-udt-7b34edc8e7079a16755e342694173e9d194425d7.zip
feat: add the network card
Two lines on one chart against a shared ceiling, one sample per pixel column so nothing is interpolated. The history is module state sized from the card's width, so moving the card reframes the window rather than clearing it. The public address is refused once stale: an address that may no longer be yours, displayed with confidence, is worse than a dash. The fixture uses TEST-NET-1. This repository is public. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--lib/data.lua25
-rw-r--r--test/fixtures/ip_addr3
-rw-r--r--test/test_data.lua13
-rw-r--r--widgets/network.lua173
4 files changed, 214 insertions, 0 deletions
diff --git a/lib/data.lua b/lib/data.lua
index 98c6898..4803f5b 100644
--- a/lib/data.lua
+++ b/lib/data.lua
@@ -378,4 +378,29 @@ function M.board_name(vendor, name)
return name or vendor
end
+-- The IPv4 address from `ip -4 addr show <iface>` output.
+--
+-- Split from the command that produces it so it can be tested against a
+-- fixture: every other parser in this file follows the same split, and an
+-- address is exactly the kind of value that must not be hardcoded in a test.
+function M.iface_addr_parse(text)
+ if type(text) ~= 'string' then return nil end
+ return text:match('inet%s+(%d+%.%d+%.%d+%.%d+)')
+end
+
+-- The interface's IPv4 address, or nil when it has none.
+--
+-- This shells out, which the draw hook otherwise never does, so the caller
+-- memoizes it: an address does not change without an event this dashboard
+-- does not watch. Retried while nil, because a bridge may not be up when
+-- conky starts.
+function M.iface_addr(iface)
+ if type(iface) ~= 'string' then return nil end
+ local p = io.popen('ip -4 addr show ' .. ("%q"):format(iface) .. ' 2>/dev/null')
+ if not p then return nil end
+ local out = p:read('*a')
+ p:close()
+ return M.iface_addr_parse(out)
+end
+
return M
diff --git a/test/fixtures/ip_addr b/test/fixtures/ip_addr
new file mode 100644
index 0000000..8d3f212
--- /dev/null
+++ b/test/fixtures/ip_addr
@@ -0,0 +1,3 @@
+4: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
+ inet 192.0.2.15/24 brd 192.0.2.255 scope global br0
+ valid_lft forever preferred_lft forever
diff --git a/test/test_data.lua b/test/test_data.lua
index 74167bf..34021c5 100644
--- a/test/test_data.lua
+++ b/test/test_data.lua
@@ -309,4 +309,17 @@ assert(data.cpu_model('model name\t: AMD Ryzen 9 7950X3D 16-Core Processor\n')
== 'Ryzen 9 7950X3D',
'x3d, got ' .. tostring(data.cpu_model('model name\t: AMD Ryzen 9 7950X3D 16-Core Processor\n')))
+-- === Interface address ====================================================
+-- The fixture uses TEST-NET-1 (192.0.2.0/24, RFC 5737). This repository is
+-- public: no real LAN address belongs in it, and no test may assert one.
+local addr = data.iface_addr_parse(read('test/fixtures/ip_addr'))
+assert(addr == '192.0.2.15', 'lan address, got ' .. tostring(addr))
+
+-- A down interface has no inet line. nil, so the card shows '--' rather than
+-- a stale or invented address.
+assert(data.iface_addr_parse('5: br0: <BROADCAST,MULTICAST> mtu 1500 state DOWN\n') == nil,
+ 'a down interface gives nil')
+assert(data.iface_addr_parse('') == nil, 'empty gives nil')
+assert(data.iface_addr_parse(nil) == nil, 'nil gives nil')
+
print('test_data: all assertions passed')
diff --git a/widgets/network.lua b/widgets/network.lua
new file mode 100644
index 0000000..e0ff4cb
--- /dev/null
+++ b/widgets/network.lua
@@ -0,0 +1,173 @@
+-- Network: throughput as two lines on one chart, with the LAN and public
+-- addresses.
+--
+-- The rates come from the interface's own byte counters, read every draw: two
+-- file reads, no subprocess. The public address comes from a cache file,
+-- because a network call in the draw hook would freeze the dashboard when it
+-- hung.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+
+local M = {}
+
+-- The interface to graph. br0 is this host's bridge, matching the old text
+-- config.
+--
+-- Note what this means: a bridge carries VM-to-host traffic that never reaches
+-- the router, so a local copy spikes the graph. That was true of the old
+-- config too and is accepted; this constant is the one edit that changes it.
+local IFACE = 'br0'
+
+local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
+ .. '/udt/pubip.txt'
+
+-- A public address older than this is not shown. Eight sampling intervals: by
+-- then the sampler has failed repeatedly, and an address that may no longer be
+-- yours displayed with confidence is worse than '--'.
+local STALE_AFTER = 4 * 3600
+
+local STAT = '/sys/class/net/' .. IFACE .. '/statistics/'
+
+-- Module state: the history outlives the frame, which is the whole point.
+-- It does not outlive a conky restart, so the chart starts empty and fills
+-- left to right.
+local rx_counter, tx_counter
+local rx_hist, tx_hist = {}, {}
+local hist_len = 0
+local lan_addr = nil
+
+-- Append to a fixed-length history, dropping the oldest. A plain array with
+-- table.remove(1) rather than a circular buffer with an index: the lengths
+-- here are a few hundred at most and the draw is every two seconds, so the
+-- O(n) shift is free and the array is already in draw order.
+local function push(hist, v, len)
+ hist[#hist + 1] = v
+ while #hist > len do table.remove(hist, 1) end
+end
+
+local function read_counter(file)
+ local s = data.slurp(STAT .. file)
+ if not s then return nil end
+ return tonumber(s:match('^%s*(%d+)'))
+end
+
+-- Bytes per second to a short string. Deliberately not card.human: a rate
+-- wants a '/s' and one decimal at most, and reusing card.human would put a
+-- suffix meant for capacity onto a speed.
+local function rate_str(bps)
+ if not bps then return '--' end
+ local units = { 'B', 'K', 'M', 'G' }
+ local n, i = bps, 1
+ while n >= 1024 and i < #units do n = n / 1024; i = i + 1 end
+ if i == 1 then return string.format('%d%s/s', math.floor(n), units[i]) end
+ if n >= 100 then return string.format('%.0f%s/s', n, units[i]) end
+ return string.format('%.1f%s/s', n, units[i])
+end
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
+
+ local interval = (conky_info and conky_info.update_interval) or 2
+ rx_counter = rx_counter or data.new_rate_counter(interval)
+ tx_counter = tx_counter or data.new_rate_counter(interval)
+
+ local down = rx_counter:sample(read_counter('rx_bytes'))
+ local up = tx_counter:sample(read_counter('tx_bytes'))
+
+ -- Fluid type, as every other card does: one base size S drives everything,
+ -- taken as the smaller of a height budget and a measured width fit.
+ local LABEL_F, BIG_F, ROW_F = 0.30, 0.62, 0.30
+ local groups = {
+ { { 'NET', card.FONT_MONO, LABEL_F }, { '888.8M/s', card.FONT_HEAVY, BIG_F } },
+ { { '\u{F0318} 192.000.000.000', card.FONT_MONO, ROW_F } },
+ { { '\u{F01DA} 888.8M/s', card.FONT_MONO, ROW_F },
+ { '\u{F0552} 888.8M/s', card.FONT_MONO, ROW_F } },
+ }
+ local fixed_units = 1.0 + ROW_F * 2.2 * 3
+ local S = clamp(math.min(inner.h * 0.96 / (fixed_units + 1.2),
+ card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
+ local label_size = S * LABEL_F
+ local row_size = S * ROW_F
+
+ -- Header: the label left, the download rate as the big value at the right.
+ -- Download is the headline because it is the number that moves.
+ card.font(cr, card.FONT_HEAVY, S * BIG_F, false)
+ card.rgba(cr, colors.body)
+ card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, rate_str(down))
+
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, inner.y + label_size, 'NET')
+
+ local ey = inner.y + S * BIG_F + row_size * 1.4
+ local step = row_size * 2.2
+
+ -- The addresses. LAN is memoized on first success and retried while nil: a
+ -- bridge may not be up when conky starts.
+ lan_addr = lan_addr or data.iface_addr(IFACE)
+ card.font(cr, card.FONT_MONO, row_size, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, '\u{F0318}') -- LAN
+ card.rgba(cr, colors.value)
+ card.text_right(cr, inner.x + inner.w, ey, lan_addr or '--')
+ ey = ey + step
+
+ local pub = data.kv_parse(data.slurp(CACHE) or '')
+ local fetched = tonumber(pub.fetched)
+ local pub_txt = '--'
+ if pub.ip and fetched and (os.time() - fetched) < STALE_AFTER then
+ pub_txt = pub.ip
+ end
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, '\u{F059F}') -- globe
+ card.rgba(cr, pub_txt == '--' and colors.label or colors.value)
+ card.text_right(cr, inner.x + inner.w, ey, pub_txt)
+ ey = ey + step * 1.1
+
+ -- The chart takes the room left between the addresses and the rate row.
+ local rate_row_h = row_size * 2.4
+ local chart_top = ey
+ local chart_h = (inner.y + inner.h) - chart_top - rate_row_h
+ if chart_h > 12 then
+ -- One sample per pixel column, so the chart never interpolates. The
+ -- buffer is resized when the card is, keeping what it can: a moved card
+ -- should not clear the history.
+ local want = math.max(8, math.floor(inner.w))
+ if want ~= hist_len then
+ hist_len = want
+ while #rx_hist > hist_len do table.remove(rx_hist, 1) end
+ while #tx_hist > hist_len do table.remove(tx_hist, 1) end
+ end
+ if down then push(rx_hist, down, hist_len) end
+ if up then push(tx_hist, up, hist_len) end
+
+ -- One ceiling for both series. Scaled independently they would lie about
+ -- their relative size, which is the entire reason to draw them together.
+ local peak = 0
+ for _, v in ipairs(rx_hist) do if v > peak then peak = v end end
+ for _, v in ipairs(tx_hist) do if v > peak then peak = v end end
+
+ card.plot(cr, inner.x, chart_top, inner.w, chart_h, {
+ { values = rx_hist, colour = colors.ok },
+ { values = tx_hist, colour = colors.highlight },
+ }, peak, colors)
+
+ -- The peak, so a full-height line means something in absolute terms.
+ card.font(cr, card.FONT_MONO, row_size * 0.85, false)
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, chart_top + row_size * 0.85, rate_str(peak))
+ end
+
+ -- The live rates, each in its series colour so the line and the number are
+ -- unmistakably the same thing.
+ local ry = inner.y + inner.h - row_size * 0.4
+ card.font(cr, card.FONT_MONO, row_size, false)
+ card.rgba(cr, colors.ok)
+ card.text(cr, inner.x, ry, '\u{F01DA} ' .. rate_str(down))
+ card.rgba(cr, colors.highlight)
+ card.text_right(cr, inner.x + inner.w, ry, '\u{F0552} ' .. rate_str(up))
+end
+
+return M