From beae868e56c2dfd6e15471dc88c91034b0842059 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 7 Aug 2023 02:49:52 +0200 Subject: [PATCH] ui: visualize bitcoind status report on the tab panel the bitcoin tab has now some basic status and stats: parts of blockchain info, network info and mempool. this is far from complete but makes a good start for an initial version. the gui playground is also updated to sent some stub info via comms periodically. --- src/ngui.zig | 5 ++ src/test/guiplay.zig | 48 ++++++++++++++++- src/ui/bitcoin.zig | 125 +++++++++++++++++++++++++++++++++++++++++++ src/ui/c/ui.c | 18 +++---- src/ui/ui.zig | 9 ++++ src/xfmt.zig | 31 +++++++++++ 6 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 src/ui/bitcoin.zig create mode 100644 src/xfmt.zig diff --git a/src/ngui.zig b/src/ngui.zig index 0641a1f..7a803ac 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -185,6 +185,11 @@ fn commThreadLoopCycle() !void { defer ui_mutex.unlock(); ui.poweroff.updateStatus(report) catch |err| logger.err("poweroff.updateStatus: {any}", .{err}); }, + .bitcoind_report => |rep| { + ui_mutex.lock(); + defer ui_mutex.unlock(); + ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err}); + }, else => logger.warn("unhandled msg tag {s}", .{@tagName(msg)}), } } diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig index 988e118..3063b97 100644 --- a/src/test/guiplay.zig +++ b/src/test/guiplay.zig @@ -72,7 +72,7 @@ fn parseArgs(gpa: std.mem.Allocator) !Flags { return flags; } -fn commThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { +fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { comm.write(gpa, w, .ping) catch |err| logger.err("comm.write ping: {any}", .{err}); while (true) { @@ -125,6 +125,47 @@ fn commThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { sigquit.set(); } +fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { + var sectimer = try time.Timer.start(); + var block_count: u64 = 801365; + + while (true) { + time.sleep(time.ns_per_s); + if (sectimer.read() < time.ns_per_s) { + continue; + } + + sectimer.reset(); + block_count += 1; + const now = time.timestamp(); + + const btcrep: comm.Message.BitcoindReport = .{ + .blocks = block_count, + .headers = block_count, + .timestamp = @intCast(u64, now), + .hash = "00000000000000000002bf8029f6be4e40b4a3e0e161b6a1044ddaf9eb126504", + .ibd = false, + .verifyprogress = 100, + .diskusage = 567119364054, + .version = "/Satoshi:24.0.1/", + .conn_in = 8, + .conn_out = 10, + .warnings = "", + .localaddr = &.{}, + .mempool = .{ + .loaded = true, + .txcount = 100000 + block_count, + .usage = std.math.min(200123456 + block_count * 10, 300000000), + .max = 300000000, + .totalfee = 2.23049932, + .minfee = 0.00004155, + .fullrbf = false, + }, + }; + comm.write(gpa, w, .{ .bitcoind_report = btcrep }) catch |err| logger.err("comm.write: {any}", .{err}); + } +} + pub fn main() !void { var gpa_state = std.heap.GeneralPurposeAllocator(.{}){}; defer if (gpa_state.deinit()) { @@ -145,7 +186,10 @@ pub fn main() !void { // ngui proc stdio is auto-closed as soon as its main process terminates. const uireader = ngui_proc.stdout.?.reader(); const uiwriter = ngui_proc.stdin.?.writer(); - _ = try std.Thread.spawn(.{}, commThread, .{ gpa, uireader, uiwriter }); + const th1 = try std.Thread.spawn(.{}, commReadThread, .{ gpa, uireader, uiwriter }); + th1.detach(); + const th2 = try std.Thread.spawn(.{}, commWriteThread, .{ gpa, uiwriter }); + th2.detach(); const sa = os.Sigaction{ .handler = .{ .handler = sighandler }, diff --git a/src/ui/bitcoin.zig b/src/ui/bitcoin.zig new file mode 100644 index 0000000..dab529e --- /dev/null +++ b/src/ui/bitcoin.zig @@ -0,0 +1,125 @@ +//! bitcoin main tab panel. +//! all functions assume LVGL is init'ed and ui mutex is locked on entry. + +const std = @import("std"); +const fmt = std.fmt; + +const lvgl = @import("lvgl.zig"); +const comm = @import("../comm.zig"); +const xfmt = @import("../xfmt.zig"); + +const logger = std.log.scoped(.ui); +/// label color mark start to make "label:" part of a "label: value" +/// in a different color. +const cmark = "#bbbbbb "; + +var tab: struct { + // blockchain section + currblock: lvgl.Label, + timestamp: lvgl.Label, + blockhash: lvgl.Label, + // usage section + diskusage: lvgl.Label, + conn_in: lvgl.Label, + conn_out: lvgl.Label, + // mempool section + mempool: struct { + txcount: lvgl.Label, + totalfee: lvgl.Label, + usage_bar: lvgl.Bar, + usage_lab: lvgl.Label, + }, +} = undefined; + +/// creates the tab content with all elements. +/// must be called only once at UI init. +pub fn initTabPanel(cont: lvgl.Container) !void { + const parent = cont.flex(.column, .{}); + + // blockchain section + { + const card = try lvgl.Card.new(parent, "BLOCKCHAIN"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.currblock = try lvgl.Label.new(left, "HEIGHT\n", .{ .recolor = true }); + tab.timestamp = try lvgl.Label.new(left, "TIMESTAMP\n", .{ .recolor = true }); + tab.blockhash = try lvgl.Label.new(row, "BLOCK HASH\n", .{ .recolor = true }); + tab.blockhash.flexGrow(1); + } + + // mempool section + { + const card = try lvgl.Card.new(parent, "MEMPOOL"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(8, .top, .{}); + left.setPad(10, .row, .{}); + tab.mempool.usage_bar = try lvgl.Bar.new(left); + tab.mempool.usage_lab = try lvgl.Label.new(left, "0Mb out of 0Mb (0%)", .{ .recolor = true }); + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.mempool.txcount = try lvgl.Label.new(right, "TRANSACTIONS COUNT\n", .{ .recolor = true }); + tab.mempool.totalfee = try lvgl.Label.new(right, "TOTAL FEES\n", .{ .recolor = true }); + } + + // usage section + { + const card = try lvgl.Card.new(parent, "USAGE"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.diskusage = try lvgl.Label.new(left, "DISK USAGE\n", .{ .recolor = true }); + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.conn_in = try lvgl.Label.new(right, "CONNECTIONS IN\n", .{ .recolor = true }); + tab.conn_out = try lvgl.Label.new(right, "CONNECTIONS OUT\n", .{ .recolor = true }); + } +} + +/// updates the tab with new data from the report. +/// the tab must be inited first with initTabPanel. +pub fn updateTabPanel(rep: comm.Message.BitcoindReport) !void { + var buf: [512]u8 = undefined; + + // blockchain section + try tab.currblock.setTextFmt(&buf, cmark ++ "HEIGHT#\n{d}", .{rep.blocks}); + try tab.timestamp.setTextFmt(&buf, cmark ++ "TIMESTAMP#\n{}", .{xfmt.unix(rep.timestamp)}); + try tab.blockhash.setTextFmt(&buf, cmark ++ "BLOCK HASH#\n{s}\n{s}", .{ rep.hash[0..32], rep.hash[32..] }); + + // mempool section + const mempool_pct: f32 = pct: { + if (rep.mempool.usage > rep.mempool.max) { + break :pct 100; + } + if (rep.mempool.max == 0) { + break :pct 0; + } + const v = @intToFloat(f64, rep.mempool.usage) / @intToFloat(f64, rep.mempool.max); + break :pct @floatCast(f32, v * 100); + }; + tab.mempool.usage_bar.setValue(@floatToInt(i32, @round(mempool_pct))); + try tab.mempool.usage_lab.setTextFmt(&buf, "{:.1} " ++ cmark ++ "out of# {:.1} ({d:.1}%)", .{ + fmt.fmtIntSizeBin(rep.mempool.usage), + fmt.fmtIntSizeBin(rep.mempool.max), + mempool_pct, + }); + try tab.mempool.txcount.setTextFmt(&buf, cmark ++ "TRANSACTIONS COUNT#\n{d}", .{rep.mempool.txcount}); + try tab.mempool.totalfee.setTextFmt(&buf, cmark ++ "TOTAL FEES#\n{d:10} BTC", .{rep.mempool.totalfee}); + + // usage section + try tab.diskusage.setTextFmt(&buf, cmark ++ "DISK USAGE#\n{:.1}", .{fmt.fmtIntSizeBin(rep.diskusage)}); + try tab.conn_in.setTextFmt(&buf, cmark ++ "CONNECTIONS IN#\n{d}", .{rep.conn_in}); + try tab.conn_out.setTextFmt(&buf, cmark ++ "CONNECTIONS OUT#\n{d}", .{rep.conn_out}); +} diff --git a/src/ui/c/ui.c b/src/ui/c/ui.c index 66d6dff..bb12fb9 100644 --- a/src/ui/c/ui.c +++ b/src/ui/c/ui.c @@ -20,6 +20,11 @@ void nm_sys_shutdown(); */ int nm_create_info_panel(lv_obj_t *parent); +/** + * creates the bitcoin tab panel. + */ +int nm_create_bitcoin_panel(lv_obj_t *parent); + /** * invoken when the UI is switched to the network settings tab. */ @@ -103,15 +108,6 @@ static void textarea_event_cb(lv_event_t *e) } } -static void create_bitcoin_panel(lv_obj_t *parent) -{ - lv_obj_t *label = lv_label_create(parent); - lv_label_set_text_static(label, - "bitcoin tab isn't designed yet\n" - "follow https://nakamochi.io"); - lv_obj_center(label); -} - static void create_lnd_panel(lv_obj_t *parent) { lv_obj_t *label = lv_label_create(parent); @@ -338,7 +334,9 @@ extern int nm_ui_init(lv_disp_t *disp) if (tab_btc == NULL) { return -1; } - create_bitcoin_panel(tab_btc); + if (nm_create_bitcoin_panel(tab_btc) != 0) { + return -1; + } lv_obj_t *tab_lnd = lv_tabview_add_tab(tabview, NM_SYMBOL_BOLT " LIGHTNING"); if (tab_lnd == NULL) { diff --git a/src/ui/ui.zig b/src/ui/ui.zig index 9c50a47..e23f37b 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -7,6 +7,7 @@ const drv = @import("drv.zig"); const symbol = @import("symbol.zig"); const widget = @import("widget.zig"); pub const poweroff = @import("poweroff.zig"); +pub const bitcoin = @import("bitcoin.zig"); const logger = std.log.scoped(.ui); @@ -34,6 +35,14 @@ export fn nm_create_info_panel(parent: *lvgl.LvObj) c_int { return 0; } +export fn nm_create_bitcoin_panel(parent: *lvgl.LvObj) c_int { + bitcoin.initTabPanel(lvgl.Container{ .lvobj = parent }) catch |err| { + logger.err("createBitcoinPanel: {any}", .{err}); + return -1; + }; + return 0; +} + fn createInfoPanel(cont: lvgl.Container) !void { const flex = cont.flex(.column, .{}); var buf: [100]u8 = undefined; diff --git a/src/xfmt.zig b/src/xfmt.zig new file mode 100644 index 0000000..c55562f --- /dev/null +++ b/src/xfmt.zig @@ -0,0 +1,31 @@ +//! extra formatting utilities, missing from std.fmt. + +const std = @import("std"); + +/// formats a unix timestamp in YYYY-MM-DD HH:MM:SS UTC. +/// if the sec value greater than u47, outputs raw digits. +pub fn unix(sec: u64) std.fmt.Formatter(formatUnix) { + return .{ .data = sec }; +} + +fn formatUnix(sec: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { + _ = fmt; // unused + _ = opts; + if (sec > std.math.maxInt(u47)) { + // EpochSeconds.getEpochDay trucates to u47 which results in a "truncated bits" + // panic for too big numbers. so, just print raw digits. + return std.fmt.format(w, "{d}", .{sec}); + } + const epoch: std.time.epoch.EpochSeconds = .{ .secs = sec }; + const daysec = epoch.getDaySeconds(); + const yearday = epoch.getEpochDay().calculateYearDay(); + const monthday = yearday.calculateMonthDay(); + return std.fmt.format(w, "{d}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2} UTC", .{ + yearday.year, + monthday.month.numeric(), + monthday.day_index + 1, + daysec.getHoursIntoDay(), + daysec.getMinutesIntoHour(), + daysec.getSecondsIntoMinute(), + }); +}