diff --git a/src/ngui.zig b/src/ngui.zig index 0055b14..01682e2 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -42,6 +42,7 @@ var last_report: struct { mu: std.Thread.Mutex = .{}, network: ?comm.ParsedMessage = null, // NetworkReport bitcoind: ?comm.ParsedMessage = null, // BitcoinReport + lightning: ?comm.ParsedMessage = null, // LightningReport fn deinit(self: *@This()) void { self.mu.lock(); @@ -54,6 +55,10 @@ var last_report: struct { v.deinit(); self.bitcoind = null; } + if (self.lightning) |v| { + v.deinit(); + self.lightning = null; + } } fn replace(self: *@This(), new: comm.ParsedMessage) void { @@ -73,6 +78,12 @@ var last_report: struct { } self.bitcoind = new; }, + .lightning_report => { + if (self.lightning) |old| { + old.deinit(); + } + self.lightning = new; + }, else => |t| logger.err("last_report: replace: unhandled tag {}", .{t}), } } @@ -220,8 +231,10 @@ fn commThreadLoopCycle() !void { switch (state) { .standby => switch (msg.value) { .ping => try comm.write(gpa, stdout, comm.Message.pong), - .network_report => last_report.replace(msg), - .bitcoind_report => last_report.replace(msg), + .network_report, + .bitcoind_report, + .lightning_report, + => last_report.replace(msg), else => logger.debug("ignoring {s}: in standby", .{@tagName(msg.value)}), }, .active, .alert => switch (msg.value) { @@ -238,6 +251,10 @@ fn commThreadLoopCycle() !void { ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err}); last_report.replace(msg); }, + .lightning_report => |rep| { + ui.lightning.updateTabPanel(rep) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); + last_report.replace(msg); + }, else => logger.warn("unhandled msg tag {s}", .{@tagName(msg.value)}), }, } diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig index 29e3999..d8802e5 100644 --- a/src/test/guiplay.zig +++ b/src/test/guiplay.zig @@ -128,7 +128,7 @@ fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { var sectimer = try time.Timer.start(); - var block_count: u64 = 801365; + var block_count: u32 = 801365; while (true) { time.sleep(time.ns_per_s); @@ -164,6 +164,76 @@ fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { }, }; comm.write(gpa, w, .{ .bitcoind_report = btcrep }) catch |err| logger.err("comm.write: {any}", .{err}); + + if (block_count % 2 == 0) { + const lndrep: comm.Message.LightningReport = .{ + .version = "0.16.4-beta commit=v0.16.4-beta", + .pubkey = "142874abcdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac982de822a", + .alias = "testnode", + .npeers = 15, + .height = block_count, + .hash = "00000000000000000002bf8029f6be4e40b4a3e0e161b6a1044ddaf9eb126504", + .sync = .{ .chain = true, .graph = true }, + .uris = &.{}, // TODO + .totalbalance = .{ .local = 10123567, .remote = 4239870, .unsettled = 0, .pending = 430221 }, + .totalfees = .{ .day = 13, .week = 132, .month = 1321 }, + .channels = &.{ + .{ + .id = null, + .state = .pending_open, + .private = false, + .point = "1b332afe982befbdcbadff33099743099eef00bcdbaef788320db328efeaa91b:0", + .closetxid = null, + .peer_pubkey = "def3829fbdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac229aaabc2", + .peer_alias = "chan-peer-alias1", + .capacity = 900000, + .balance = .{ .local = 1123456, .remote = 0, .unsettled = 0, .limbo = 0 }, + .totalsats = .{ .sent = 0, .received = 0 }, + .fees = .{ .base = 0, .ppm = 0 }, + }, + .{ + .id = null, + .state = .pending_close, + .private = false, + .point = "932baef3982befbdcbadff33099743099eef00bcdbaef788320db328e82afdd7:0", + .closetxid = "fe829832982befbdcbadff33099743099eef00bcdbaef788320db328eaffeb2b", + .peer_pubkey = "01feba38fe8adbeef8839bdfaf8439fac9b0327bf78acdee8928efbac2abfec831", + .peer_alias = "chan-peer-alias2", + .capacity = 800000, + .balance = .{ .local = 10000, .remote = 788000, .unsettled = 0, .limbo = 10000 }, + .totalsats = .{ .sent = 0, .received = 0 }, + .fees = .{ .base = 0, .ppm = 0 }, + }, + .{ + .id = "848352385882718209", + .state = .active, + .private = false, + .point = "36277666abcbefbdcbadff33099743099eef00bcdbaef788320db328e828e00d:1", + .closetxid = null, + .peer_pubkey = "e7287abcfdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac229acddbe", + .peer_alias = "chan-peer-alias3", + .capacity = 1000000, + .balance = .{ .local = 1000000 / 2, .remote = 1000000 / 2, .unsettled = 0, .limbo = 0 }, + .totalsats = .{ .sent = 3287320, .received = 2187482 }, + .fees = .{ .base = 1000, .ppm = 400 }, + }, + .{ + .id = "134439885882718428", + .state = .inactive, + .private = false, + .point = "abafe483982befbdcbadff33099743099eef00bcdbaef788320db328e828339c:0", + .closetxid = null, + .peer_pubkey = "20398287fdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac229a03928", + .peer_alias = "chan-peer-alias4", + .capacity = 900000, + .balance = .{ .local = 900000, .remote = 0, .unsettled = 0, .limbo = 0 }, + .totalsats = .{ .sent = 328732, .received = 2187482 }, + .fees = .{ .base = 1000, .ppm = 500 }, + }, + }, + }; + comm.write(gpa, w, .{ .lightning_report = lndrep }) catch |err| logger.err("comm.write: {any}", .{err}); + } } } diff --git a/src/ui/c/ui.c b/src/ui/c/ui.c index bb12fb9..eb28f4c 100644 --- a/src/ui/c/ui.c +++ b/src/ui/c/ui.c @@ -25,6 +25,11 @@ int nm_create_info_panel(lv_obj_t *parent); */ int nm_create_bitcoin_panel(lv_obj_t *parent); +/** + * creates the lightning tab panel. + */ +int nm_create_lightning_panel(lv_obj_t *parent); + /** * invoken when the UI is switched to the network settings tab. */ @@ -108,15 +113,6 @@ static void textarea_event_cb(lv_event_t *e) } } -static void create_lnd_panel(lv_obj_t *parent) -{ - lv_obj_t *label = lv_label_create(parent); - lv_label_set_text_static(label, - "lightning tab isn't designed yet\n" - "follow https://nakamochi.io"); - lv_obj_center(label); -} - static struct { lv_obj_t *wifi_spinner_obj; /* lv_spinner_create */ lv_obj_t *wifi_status_obj; /* lv_label_create */ @@ -342,7 +338,9 @@ extern int nm_ui_init(lv_disp_t *disp) if (tab_lnd == NULL) { return -1; } - create_lnd_panel(tab_lnd); + if (nm_create_lightning_panel(tab_lnd) != 0) { + return -1; + } lv_obj_t *tab_settings = lv_tabview_add_tab(tabview, LV_SYMBOL_SETTINGS " SETTINGS"); if (tab_settings == NULL) { diff --git a/src/ui/lightning.zig b/src/ui/lightning.zig new file mode 100644 index 0000000..3b7100a --- /dev/null +++ b/src/ui/lightning.zig @@ -0,0 +1,198 @@ +//! lightning main tab panel and other functionality. +//! all functions assume LVGL is init'ed and ui mutex is locked on entry. + +const std = @import("std"); + +const comm = @import("../comm.zig"); +const lvgl = @import("lvgl.zig"); +const xfmt = @import("../xfmt.zig"); + +const logger = std.log.scoped(.ui_lnd); +/// label color mark start to make "label:" part of a "label: value" +/// in a different color. +const cmark = "#bbbbbb "; + +var tab: struct { + info: struct { + alias: lvgl.Label, + blockhash: lvgl.Label, + currblock: lvgl.Label, + npeers: lvgl.Label, + pubkey: lvgl.Label, + version: lvgl.Label, + }, + balance: struct { + avail: lvgl.Bar, // local vs remote + local: lvgl.Label, + remote: lvgl.Label, + unsettled: lvgl.Label, + pending: lvgl.Label, + fees: lvgl.Label, // day, week, month + }, + channels_cont: lvgl.FlexLayout, +} = 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, .{}); + + // info section + { + const card = try lvgl.Card.new(parent, "INFO"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setHeightToContent(); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setHeightToContent(); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.info.alias = try lvgl.Label.new(left, "ALIAS\n", .{ .recolor = true }); + tab.info.pubkey = try lvgl.Label.new(left, "PUBKEY\n", .{ .recolor = true }); + tab.info.version = try lvgl.Label.new(left, "VERSION\n", .{ .recolor = true }); + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setHeightToContent(); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.info.currblock = try lvgl.Label.new(right, "HEIGHT\n", .{ .recolor = true }); + tab.info.blockhash = try lvgl.Label.new(right, "BLOCK HASH\n", .{ .recolor = true }); + tab.info.npeers = try lvgl.Label.new(right, "CONNECTED PEERS\n", .{ .recolor = true }); + } + // balance section + { + const card = try lvgl.Card.new(parent, "BALANCE"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.balance.avail = try lvgl.Bar.new(left); + tab.balance.avail.setWidth(lvgl.sizePercent(90)); + const subrow = try lvgl.FlexLayout.new(left, .row, .{ .main = .space_between }); + subrow.setWidth(lvgl.sizePercent(90)); + subrow.setHeightToContent(); + tab.balance.local = try lvgl.Label.new(subrow, "LOCAL\n", .{ .recolor = true }); + tab.balance.remote = try lvgl.Label.new(subrow, "REMOTE\n", .{ .recolor = true }); + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.balance.pending = try lvgl.Label.new(right, "PENDING\n", .{ .recolor = true }); + tab.balance.unsettled = try lvgl.Label.new(right, "UNSETTLED\n", .{ .recolor = true }); + // bottom + tab.balance.fees = try lvgl.Label.new(card, "ACCUMULATED FORWARDING FEES\n", .{ .recolor = true }); + } + // channels section + { + const card = try lvgl.Card.new(parent, "CHANNELS"); + tab.channels_cont = try lvgl.FlexLayout.new(card, .column, .{}); + tab.channels_cont.setHeightToContent(); + tab.channels_cont.setWidth(lvgl.sizePercent(100)); + tab.channels_cont.clearFlag(.scrollable); + tab.channels_cont.setPad(10, .row, .{}); + } +} + +/// updates the tab with new data from the report. +/// the tab must be inited first with initTabPanel. +pub fn updateTabPanel(rep: comm.Message.LightningReport) !void { + var buf: [512]u8 = undefined; + + // info section + try tab.info.alias.setTextFmt(&buf, cmark ++ "ALIAS#\n{s}", .{rep.alias}); + try tab.info.pubkey.setTextFmt(&buf, cmark ++ "PUBKEY#\n{s}\n{s}", .{ rep.pubkey[0..33], rep.pubkey[33..] }); + try tab.info.version.setTextFmt(&buf, cmark ++ "VERSION#\n{s}", .{rep.version}); + try tab.info.currblock.setTextFmt(&buf, cmark ++ "HEIGHT#\n{d}", .{rep.height}); + try tab.info.blockhash.setTextFmt(&buf, cmark ++ "BLOCK HASH#\n{s}\n{s}", .{ rep.hash[0..32], rep.hash[32..] }); + try tab.info.npeers.setTextFmt(&buf, cmark ++ "CONNECTED PEERS#\n{d}", .{rep.npeers}); + + // balance section + const local_pct: i32 = pct: { + const total = rep.totalbalance.local + rep.totalbalance.remote; + if (total == 0) { + break :pct 0; + } + const v = @as(f64, @floatFromInt(rep.totalbalance.local)) / @as(f64, @floatFromInt(total)); + break :pct @intFromFloat(v * 100); + }; + tab.balance.avail.setValue(local_pct); + try tab.balance.local.setTextFmt(&buf, cmark ++ "LOCAL#\n{} sat", .{xfmt.imetric(rep.totalbalance.local)}); + try tab.balance.remote.setTextFmt(&buf, cmark ++ "REMOTE#\n{} sat", .{xfmt.imetric(rep.totalbalance.remote)}); + try tab.balance.pending.setTextFmt(&buf, cmark ++ "PENDING#\n{} sat", .{xfmt.imetric(rep.totalbalance.pending)}); + try tab.balance.unsettled.setTextFmt(&buf, cmark ++ "UNSETTLED#\n{}", .{xfmt.imetric(rep.totalbalance.unsettled)}); + try tab.balance.fees.setTextFmt(&buf, cmark ++ "ACCUMULATED FORWARDING FEES#\nDAY: {} sat WEEK: {} sat MONTH: {} sat", .{ + xfmt.umetric(rep.totalfees.day), + xfmt.umetric(rep.totalfees.week), + xfmt.umetric(rep.totalfees.month), + }); + + // channels section + tab.channels_cont.deleteChildren(); + for (rep.channels) |ch| { + const chbox = (try lvgl.Container.new(tab.channels_cont)).flex(.column, .{}); + chbox.setWidth(lvgl.sizePercent(100)); + chbox.setHeightToContent(); + _ = try switch (ch.state) { + // TODO: sanitize peer_alias? + .active => lvgl.Label.newFmt(chbox, &buf, "{s}", .{ch.peer_alias}, .{}), + .inactive => lvgl.Label.newFmt(chbox, &buf, "#ff0000 [INACTIVE]# {s}", .{ch.peer_alias}, .{ .recolor = true }), + .pending_open => lvgl.Label.new(chbox, "#00ff00 [PENDING OPEN]#", .{ .recolor = true }), + .pending_close => lvgl.Label.new(chbox, "#ffff00 [PENDING CLOSE]#", .{ .recolor = true }), + }; + const row = try lvgl.FlexLayout.new(chbox, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + row.setHeightToContent(); + + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(46)); + left.setHeightToContent(); + left.setPad(10, .row, .{}); + const bbar = try lvgl.Bar.new(left); + bbar.setWidth(lvgl.sizePercent(100)); + const chan_local_pct: i32 = pct: { + const total = ch.balance.local + ch.balance.remote; + if (total == 0) { + break :pct 0; + } + const v = @as(f64, @floatFromInt(ch.balance.local)) / @as(f64, @floatFromInt(total)); + break :pct @intFromFloat(v * 100); + }; + bbar.setValue(chan_local_pct); + const subrow = try lvgl.FlexLayout.new(left, .row, .{ .main = .space_between }); + subrow.setWidth(lvgl.sizePercent(100)); + subrow.setHeightToContent(); + const subcol1 = try lvgl.FlexLayout.new(subrow, .column, .{}); + subcol1.setPad(10, .row, .{}); + subcol1.setHeightToContent(); + const subcol2 = try lvgl.FlexLayout.new(subrow, .column, .{}); + subcol2.setPad(10, .row, .{}); + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "LOCAL#\n{} sat", .{xfmt.imetric(ch.balance.local)}, .{ .recolor = true }); + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "RECEIVED#\n{} sat", .{xfmt.imetric(ch.totalsats.received)}, .{ .recolor = true }); + if (ch.state == .active or ch.state == .inactive) { + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "BASE FEE#\n{} msat", .{xfmt.imetric(ch.fees.base)}, .{ .recolor = true }); + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "FEE PPM#\n{d}", .{ch.fees.ppm}, .{ .recolor = true }); + } + _ = try lvgl.Label.newFmt(subcol2, &buf, cmark ++ "REMOTE#\n{} sat", .{xfmt.imetric(ch.balance.remote)}, .{ .recolor = true }); + _ = try lvgl.Label.newFmt(subcol2, &buf, cmark ++ "SENT#\n{} sat", .{xfmt.imetric(ch.totalsats.sent)}, .{ .recolor = true }); + + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(54)); + right.setHeightToContent(); + right.setPad(10, .row, .{}); + if (ch.id) |id| { + _ = try lvgl.Label.newFmt(right, &buf, cmark ++ "ID#\n{s}", .{id}, .{ .recolor = true }); + } + _ = try lvgl.Label.newFmt(right, &buf, cmark ++ "FUNDING TX#\n{s}\n{s}", .{ ch.point[0..32], ch.point[32..] }, .{ .recolor = true }); + if (ch.closetxid) |tx| { + _ = try lvgl.Label.newFmt(right, &buf, cmark ++ "CLOSING TX#\n{s}\n{s}", .{ tx[0..32], tx[32..] }, .{ .recolor = true }); + } + } +} diff --git a/src/ui/ui.zig b/src/ui/ui.zig index e23f37b..5940bb9 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -8,6 +8,7 @@ const symbol = @import("symbol.zig"); const widget = @import("widget.zig"); pub const poweroff = @import("poweroff.zig"); pub const bitcoin = @import("bitcoin.zig"); +pub const lightning = @import("lightning.zig"); const logger = std.log.scoped(.ui); @@ -43,6 +44,14 @@ export fn nm_create_bitcoin_panel(parent: *lvgl.LvObj) c_int { return 0; } +export fn nm_create_lightning_panel(parent: *lvgl.LvObj) c_int { + lightning.initTabPanel(lvgl.Container{ .lvobj = parent }) catch |err| { + logger.err("createLightningPanel: {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 index db89cf4..0460f86 100644 --- a/src/xfmt.zig +++ b/src/xfmt.zig @@ -8,6 +8,16 @@ pub fn unix(sec: u64) std.fmt.Formatter(formatUnix) { return .{ .data = sec }; } +/// returns a metric formatter, outputting the value with SI unit suffix. +pub fn imetric(val: i64) std.fmt.Formatter(formatMetricI) { + return .{ .data = val }; +} + +/// returns a metric formatter, outputting the value with SI unit suffix. +pub fn umetric(val: u64) std.fmt.Formatter(formatMetricU) { + return .{ .data = val }; +} + fn formatUnix(sec: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { _ = fmt; // unused _ = opts; @@ -29,3 +39,33 @@ fn formatUnix(sec: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w daysec.getSecondsIntoMinute(), }); } + +fn formatMetricI(value: i64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { + const uval: u64 = std.math.absCast(value); + const base: u64 = 1000; + if (uval < base) { + return std.fmt.formatIntValue(value, fmt, opts, w); + } + + if (value < 0) { + try w.writeByte('-'); + } + return formatMetricU(uval, fmt, opts, w); +} + +/// based on `std.fmt.fmtIntSizeDec`. +fn formatMetricU(value: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { + const lossyCast = std.math.lossyCast; + const base: u64 = 1000; + if (value < base) { + return std.fmt.formatIntValue(value, fmt, opts, w); + } + + const mags_si = " kMGTPEZY"; + const log2 = std.math.log2(value); + const m = @min(log2 / comptime std.math.log2(base), mags_si.len - 1); + const newval = lossyCast(f64, value) / std.math.pow(f64, lossyCast(f64, base), lossyCast(f64, m)); + const suffix = mags_si[m]; + try std.fmt.formatFloatDecimal(newval, opts, w); + try w.writeByte(suffix); +}